From d80287fdc678556534f36517c6a86d7945037359 Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 1 Sep 2026 20:42:16 +0800 Subject: [PATCH 01/27] refactor(environment): define the worker wire protocol --- .../crates/environment-protocol/Cargo.toml | 20 + .../crates/environment-protocol/src/lib.rs | 1154 +++++++++++++++++ .../environment-protocol/tests/protocol.rs | 558 ++++++++ 3 files changed, 1732 insertions(+) create mode 100644 src-tauri/crates/environment-protocol/Cargo.toml create mode 100644 src-tauri/crates/environment-protocol/src/lib.rs create mode 100644 src-tauri/crates/environment-protocol/tests/protocol.rs diff --git a/src-tauri/crates/environment-protocol/Cargo.toml b/src-tauri/crates/environment-protocol/Cargo.toml new file mode 100644 index 00000000..370a95e6 --- /dev/null +++ b/src-tauri/crates/environment-protocol/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "environment-protocol" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +bytes = "1" +futures-util = { version = "0.3", features = ["sink"] } +postcard = { version = "1.1.3", features = ["use-std"] } +serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" +sha2 = "0.10.9" +thiserror = "2" +tokio = { version = "1", features = ["io-util", "macros", "rt", "sync"] } +tokio-util = { version = "0.7", features = ["codec"] } + +[dev-dependencies] +tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread", "time"] } diff --git a/src-tauri/crates/environment-protocol/src/lib.rs b/src-tauri/crates/environment-protocol/src/lib.rs new file mode 100644 index 00000000..38ae69b9 --- /dev/null +++ b/src-tauri/crates/environment-protocol/src/lib.rs @@ -0,0 +1,1154 @@ +use std::io; +use std::sync::Arc; + +use bytes::Bytes; +use futures_util::SinkExt; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinHandle; +use tokio_util::codec::{FramedWrite, LengthDelimitedCodec}; + +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; +pub const MAX_PAYLOAD_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_RESPONSE_TRANSFER_BYTES: usize = 36 * 1024 * 1024; +pub const MAX_PAYLOAD_TRANSFER_BYTES: usize = 256 * 1024 * 1024; +pub const MAX_MUTATION_TRANSFER_BYTES: usize = 36 * 1024 * 1024; +pub const MAX_CONCURRENT_READ_REQUESTS: usize = 8; +pub const MAX_PENDING_READ_REQUESTS: usize = 64; +pub const MAX_INSPECTION_ROOTS: usize = 256; +pub const MAX_INSPECTION_FACTS: usize = 65_536; +pub const MAX_INSPECTION_CONTENT_BYTES: u32 = 8 * 1024 * 1024; +pub const MAX_PATH_CONTENT_BYTES_PER_FILE: u32 = 1024 * 1024; +pub const MAX_DIRECTORY_COUNT_LIMIT: u32 = 10_000; +pub const MAX_DOCUMENT_BYTES: u32 = 16 * 1024 * 1024; +pub const MAX_MANIFEST_RECORDS: usize = 262_144; +pub const MAX_REQUEST_DEADLINE_MILLIS: u64 = 120_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum WireRecord { + Control(Envelope), + PayloadChunk { + transfer_id: u64, + #[serde(with = "serde_bytes")] + bytes: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Envelope { + pub request_id: u64, + pub message: Message, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathKind { + Missing, + File, + Directory, + SymlinkDirectory, + SymlinkOther, + BrokenLink, + Other, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionRequest { + pub roots: Vec, + pub per_file_limit: u32, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionRoot { + pub path: String, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InspectionEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InspectionErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionFact { + pub root_index: u32, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub kind: InspectionEntryKind, + pub resolved_target: Option>, + #[serde(with = "serde_bytes")] + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InspectionResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataRequest { + pub queries: Vec, + pub aggregate_content_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataQuery { + pub path: String, + pub content_limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathMetadataKind { + Missing, + Directory, + SymlinkDirectory, + SymlinkOther, + Other, + BrokenLink, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PathMetadataContent { + NotRequested, + Empty, + Unreadable, + Bytes(#[serde(with = "serde_bytes")] Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataFact { + pub path: String, + pub kind: PathMetadataKind, + pub content: PathMetadataContent, + pub content_truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathMetadataResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountRequest { + pub paths: Vec, + pub limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountFact { + pub path: String, + pub observed_count: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryCountResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadRequest { + pub queries: Vec, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadQuery { + pub path: String, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DocumentReadState { + Missing, + NotFile, + Unreadable, + Bytes(#[serde(with = "serde_bytes")] Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadFact { + pub path: String, + pub state: DocumentReadState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentReadResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentWritePreparation { + pub path: String, + pub expected_revision: Option, + pub total_bytes: u64, + pub sha256: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentRemoveRequest { + pub path: String, + pub expected_revision: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryCatalogResponse { + #[serde(with = "serde_bytes")] + pub bytes: Vec, + pub present: bool, + pub revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryOperationPreparation { + pub total_bytes: u64, + pub sha256: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryOperationRequest { + pub operation_id: String, + pub expected_catalog_revision: Option, + #[serde(with = "serde_bytes")] + pub catalog_bytes: Vec, + pub action: LibraryOperationAction, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LibraryOperationAction { + SaveCatalog { + library_ids: Vec, + }, + CommitMember { + library_id: String, + skill_name: String, + expected_anchor_device: u64, + expected_anchor_inode: u64, + expected_fingerprint: String, + expected_content_hash: Option, + mutation: LibraryMemberAction, + }, + DeleteLibrary { + library_id: String, + expected_anchor_device: u64, + expected_anchor_inode: u64, + expected_fingerprint: String, + expected_content_hash: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LibraryMemberAction { + Upsert { payload_id: u64 }, + Delete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryListRequest { + pub path: String, + pub limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectoryListResponse { + pub names: Vec>, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapWindowsPathsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapWindowsPathsResponse { + pub mapped: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapHostPathsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MapHostPathsResponse { + pub mapped: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFactsRequest { + pub paths: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryFactKind { + Missing, + File, + Directory, + Symlink, + BrokenLink, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryMetadata { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub size: u64, + pub mtime_seconds: i64, + pub mtime_nanos: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFact { + pub kind: EntryFactKind, + pub metadata: Option, + pub link_target: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFactsResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectionRequest { + pub destinations: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectedTarget { + pub anchor_device: u64, + pub anchor_inode: u64, + pub physical_destination: Vec, + pub relative_components: Vec>, + pub storage_projection: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectionResponse { + pub targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestRequest { + pub root: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ManifestRecordKind { + Directory, + File, + Symlink, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestRecord { + pub relative_path: Vec, + pub kind: ManifestRecordKind, + pub digest: Option, + pub executable: bool, + pub symlink_target: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestResponse { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitSourceRequest { + pub url: String, + pub git_ref: Option, + pub proxy: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenLocalSourceRequest { + pub path: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceScanMode { + Recursive, + PriorityDirectories, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanRoot { + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub stat_only: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanRequest { + pub source_id: u64, + pub roots: Vec, + pub mode: SourceScanMode, + pub per_file_limit: u32, + pub aggregate_limit: u32, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceEntryErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceEntry { + pub root_index: u32, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub kind: SourceEntryKind, + pub link_target: Option>, + #[serde(with = "serde_bytes")] + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceScanResponse { + pub entries: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PayloadEntryKind { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadEntry { + pub relative_path: String, + pub kind: PayloadEntryKind, + pub blob_id: Option, + pub content_hash: Option, + pub size: u64, + pub executable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadManifest { + pub entries: Vec, + pub payload_root_hash: String, + pub payload_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AcquirePayloadFromSourceRequest { + pub source_id: u64, + #[serde(with = "serde_bytes")] + pub relative_path: Vec, + pub session_id: String, + pub payload_name: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifyPayloadRequest { + pub session_id: String, + pub payload_name: String, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadReadyResponse { + pub payload_id: u64, + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadCleanupResponse { + pub removed_sessions: u32, + pub protected_sessions: u32, + pub retained_external_bytes: u64, + pub cleanup_blocked: bool, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayloadCleanupWarning { + pub code: String, + pub candidate_name: Option, + pub technical_details: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationUnitRequest { + pub resource_id: String, + pub operation_id: String, + pub unit_id: String, + #[serde(with = "serde_bytes")] + pub initial_marker_json: Vec, + pub entries: Vec, + pub lock: Option, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationEntry { + pub destination: String, + pub expected_anchor_device: u64, + pub expected_anchor_inode: u64, + pub expected_fingerprint: String, + pub expected_content_hash: Option, + pub action: MutationEntryAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationEntryAction { + Keep, + Materialize { payload_id: u64 }, + Symlink { target: String }, + Remove, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationLockSchema { + Global, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationLockEntry { + Replace { + key: String, + #[serde(with = "serde_bytes")] + replacement_json: Vec, + }, + Remove { + key: String, + }, + MoveAndReplace { + from: String, + to: String, + #[serde(with = "serde_bytes")] + replacement_json: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationLock { + pub target: String, + pub legacy_target: Option, + pub schema: MutationLockSchema, + pub entry: MutationLockEntry, + pub root_replacements_json: std::collections::BTreeMap>, + pub expected_entries_json: std::collections::BTreeMap>>, + pub expected_roots_json: std::collections::BTreeMap>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationLockReceipt { + pub entries_json: std::collections::BTreeMap>>, + pub roots_json: std::collections::BTreeMap>>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationUnitOutcome { + Succeeded { + lock: Option, + cleanup: Option, + }, + Failed { + code: String, + phase: String, + parameters: Vec<(String, String)>, + message: String, + }, + Cancelled, + RecoveryRequired { + resource_id: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationCleanupToken { + pub resource_id: String, + pub marker_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MutationRecoveryState { + Present, + Unreadable, + Unsafe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationRecoveryRecord { + pub resource_id: String, + pub managed_root: String, + pub state: MutationRecoveryState, + #[serde(with = "serde_bytes")] + pub marker_bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationRecoveryList { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Message { + Handshake { + build_id: String, + }, + HandshakeResult { + build_id: String, + distro: String, + user: String, + uid: u32, + home: String, + }, + Progress { + current: u32, + total: u32, + }, + Cancel { + target_request_id: u64, + }, + BeginTransfer { + transfer_id: u64, + total_bytes: u64, + sha256: String, + owner_request_id: u64, + }, + TransferCompleted { + transfer_id: u64, + total_bytes: u64, + sha256: String, + }, + Error { + code: String, + phase: String, + parameters: Vec<(String, String)>, + }, + Shutdown, + ObservePath { + path: String, + }, + PathObserved { + kind: PathKind, + }, + InspectFilesystem { + request: InspectionRequest, + }, + InspectPaths { + request: PathMetadataRequest, + }, + CountDirectoryEntries { + request: DirectoryCountRequest, + }, + ReadDocuments { + request: DocumentReadRequest, + }, + PrepareDocumentWrite { + request: DocumentWritePreparation, + }, + DocumentWritten { + revision: String, + }, + RemoveDocument { + request: DocumentRemoveRequest, + }, + DocumentRemoved, + ReadLibraryCatalog { + deadline_millis: u64, + }, + PrepareLibraryOperation { + request: LibraryOperationPreparation, + }, + LibraryOperationCompleted { + catalog_revision: String, + }, + ListChildDirectories { + request: DirectoryListRequest, + }, + MapPathsToWindows { + request: MapWindowsPathsRequest, + }, + InspectEntries { + request: EntryFactsRequest, + }, + ProjectTargets { + request: ProjectionRequest, + }, + BuildManifest { + request: ManifestRequest, + }, + AcquireGitSource { + request: GitSourceRequest, + }, + OpenLocalSource { + request: OpenLocalSourceRequest, + }, + SourceOpened { + source_id: u64, + root: String, + revision: Option, + }, + ReleaseSource { + source_id: u64, + }, + SourceReleased { + source_id: u64, + }, + ScanSource { + request: SourceScanRequest, + }, + SourceFingerprint { + source_id: u64, + #[serde(with = "serde_bytes")] + relative_path: Vec, + deadline_millis: u64, + }, + SourceFingerprintResult { + fingerprint: String, + }, + SourceRevision { + source_id: u64, + #[serde(with = "serde_bytes")] + relative_path: Vec, + deadline_millis: u64, + }, + SourceRevisionResult { + revision: String, + }, + ProbeGit { + request: GitSourceRequest, + }, + GitProbed { + revision: String, + }, + AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest, + }, + VerifyPayload { + request: VerifyPayloadRequest, + }, + ReadPayloadBlob { + payload_id: u64, + blob_id: String, + deadline_millis: u64, + }, + RemovePayload { + session_id: String, + payload_name: String, + }, + PayloadRemoved { + session_id: String, + payload_name: String, + }, + RemovePayloadSession { + session_id: String, + }, + PayloadSessionRemoved { + session_id: String, + }, + SweepPayloadOrphans { + protected_session_ids: Vec, + }, + BeginPayloadUpload { + session_id: String, + payload_name: String, + }, + PayloadUploadBegun { + upload_id: u64, + }, + UploadPayloadBlob { + upload_id: u64, + blob_id: String, + total_bytes: u64, + sha256: String, + }, + FinalizePayloadUpload { + upload_id: u64, + total_bytes: u64, + sha256: String, + }, + TransferReady { + transfer_id: u64, + }, + PayloadBlobUploaded { + upload_id: u64, + blob_id: String, + }, + PayloadUploadFinalized { + payload_id: u64, + }, + PrepareMutationUnit { + resource_id: String, + total_bytes: u64, + sha256: String, + }, + MutationAccepted { + resource_id: String, + }, + AcknowledgeMutationUnit { + cleanup: MutationCleanupToken, + }, + MutationAcknowledged { + resource_id: String, + }, + ListMutationRecovery, + CleanupMutationRecovery { + resource_id: String, + #[serde(with = "serde_bytes")] + expected_marker_json: Vec, + backups: Vec, + }, + MutationRecoveryCleaned { + resource_id: String, + }, + MapHostPaths { + request: MapHostPathsRequest, + }, +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + #[error("invalid postcard record: {0}")] + Postcard(#[from] postcard::Error), + #[error("wire record contains {count} trailing bytes")] + TrailingBytes { count: usize }, +} + +pub fn encode(record: &WireRecord) -> Result, postcard::Error> { + postcard::to_stdvec(record) +} + +pub fn decode(bytes: &[u8]) -> Result { + let (record, remaining) = postcard::take_from_bytes(bytes)?; + if !remaining.is_empty() { + return Err(DecodeError::TrailingBytes { + count: remaining.len(), + }); + } + Ok(record) +} + +pub fn encode_inspection_response( + response: &InspectionResponse, +) -> Result, postcard::Error> { + postcard::to_stdvec(response) +} + +pub fn encode_payload(value: &T) -> Result, postcard::Error> +where + T: Serialize, +{ + postcard::to_stdvec(value) +} + +pub fn decode_payload(bytes: &[u8]) -> Result +where + T: DeserializeOwned, +{ + let (value, remaining) = postcard::take_from_bytes(bytes)?; + if !remaining.is_empty() { + return Err(DecodeError::TrailingBytes { + count: remaining.len(), + }); + } + Ok(value) +} + +pub fn decode_inspection_response(bytes: &[u8]) -> Result { + decode_payload(bytes) +} + +pub fn codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_BYTES) + .new_codec() +} + +#[derive(Debug, thiserror::Error)] +pub enum WriterError { + #[error("protocol writer is closed")] + Closed, + #[error("failed to encode protocol record: {0}")] + Encode(#[from] postcard::Error), + #[error("failed to write protocol record: {0}")] + Io(#[from] io::Error), + #[error("binary transfer exceeds its boundary")] + TransferTooLarge, + #[error("binary transfer content does not match its declaration")] + TransferMismatch, +} + +#[derive(Clone)] +pub struct ProtocolWriter { + control: mpsc::Sender, + binary: mpsc::Sender, + transfer_gate: Arc>, +} + +enum BinaryRecord { + Record(WireRecord), + Barrier(WireRecord, oneshot::Sender<()>), +} + +impl ProtocolWriter { + pub async fn send_control(&self, record: WireRecord) -> Result<(), WriterError> { + self.control + .send(record) + .await + .map_err(|_| WriterError::Closed) + } + + pub async fn send_binary(&self, record: WireRecord) -> Result<(), WriterError> { + self.binary + .send(BinaryRecord::Record(record)) + .await + .map_err(|_| WriterError::Closed) + } + + pub async fn send_binary_barrier(&self, record: WireRecord) -> Result<(), WriterError> { + let (written_tx, written_rx) = oneshot::channel(); + self.binary + .send(BinaryRecord::Barrier(record, written_tx)) + .await + .map_err(|_| WriterError::Closed)?; + written_rx.await.map_err(|_| WriterError::Closed) + } + + pub async fn send_transfer( + &self, + owner_request_id: u64, + transfer_id: u64, + payload: &[u8], + ) -> Result<(), WriterError> { + self.send_transfer_with_limit( + owner_request_id, + transfer_id, + payload, + MAX_RESPONSE_TRANSFER_BYTES, + ) + .await + } + + pub async fn send_transfer_with_limit( + &self, + owner_request_id: u64, + transfer_id: u64, + payload: &[u8], + transfer_limit: usize, + ) -> Result<(), WriterError> { + if payload.len() > transfer_limit || payload.len() > MAX_PAYLOAD_TRANSFER_BYTES { + return Err(WriterError::TransferTooLarge); + } + let _guard = self.transfer_gate.lock().await; + let total_bytes = payload.len() as u64; + let sha256 = format!("sha256:{:x}", Sha256::digest(payload)); + self.send_binary(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::BeginTransfer { + transfer_id, + total_bytes, + sha256: sha256.clone(), + owner_request_id, + }, + })) + .await?; + for chunk in payload.chunks(MAX_PAYLOAD_CHUNK_BYTES) { + self.send_binary(WireRecord::PayloadChunk { + transfer_id, + bytes: chunk.to_vec(), + }) + .await?; + } + self.send_binary_barrier(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + }, + })) + .await + } + + pub async fn send_reader_transfer_with_limit( + &self, + owner_request_id: u64, + transfer_id: u64, + mut reader: R, + total_bytes: u64, + sha256: String, + transfer_limit: usize, + ) -> Result<(), WriterError> + where + R: AsyncRead + Unpin, + { + if total_bytes > transfer_limit as u64 || total_bytes > MAX_PAYLOAD_TRANSFER_BYTES as u64 { + return Err(WriterError::TransferTooLarge); + } + let _guard = self.transfer_gate.lock().await; + self.send_binary(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::BeginTransfer { + transfer_id, + total_bytes, + sha256: sha256.clone(), + owner_request_id, + }, + })) + .await?; + let mut hasher = Sha256::new(); + let mut received = 0_u64; + let mut buffer = vec![0_u8; MAX_PAYLOAD_CHUNK_BYTES]; + while received < total_bytes { + let remaining = usize::try_from(total_bytes - received) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let read = reader.read(&mut buffer[..remaining]).await?; + if read == 0 { + return Err(WriterError::TransferMismatch); + } + received += read as u64; + hasher.update(&buffer[..read]); + self.send_binary(WireRecord::PayloadChunk { + transfer_id, + bytes: buffer[..read].to_vec(), + }) + .await?; + } + let mut extra = [0_u8; 1]; + if reader.read(&mut extra).await? != 0 + || format!("sha256:{:x}", hasher.finalize()) != sha256 + { + return Err(WriterError::TransferMismatch); + } + self.send_binary_barrier(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + }, + })) + .await + } +} + +pub fn spawn_writer(output: W) -> (ProtocolWriter, JoinHandle>) +where + W: AsyncWrite + Unpin + Send + 'static, +{ + let (control_tx, mut control_rx) = mpsc::channel(64); + let (binary_tx, mut binary_rx) = mpsc::channel(8); + let task = tokio::spawn(async move { + let mut sink = FramedWrite::new(output, codec()); + let mut control_open = true; + let mut binary_open = true; + let mut control_first = true; + while control_open || binary_open { + let selected = if control_first { + tokio::select! { + biased; + value = control_rx.recv(), if control_open => match value { + Some(record) => Some((record, None)), + None => { control_open = false; None } + }, + value = binary_rx.recv(), if binary_open => match value { + Some(BinaryRecord::Record(record)) => Some((record, None)), + Some(BinaryRecord::Barrier(record, written)) => Some((record, Some(written))), + None => { binary_open = false; None } + }, + } + } else { + tokio::select! { + biased; + value = binary_rx.recv(), if binary_open => match value { + Some(BinaryRecord::Record(record)) => Some((record, None)), + Some(BinaryRecord::Barrier(record, written)) => Some((record, Some(written))), + None => { binary_open = false; None } + }, + value = control_rx.recv(), if control_open => match value { + Some(record) => Some((record, None)), + None => { control_open = false; None } + }, + } + }; + let Some((record, written)) = selected else { + continue; + }; + control_first = !control_first; + sink.send(Bytes::from(encode(&record)?)).await?; + if let Some(written) = written { + let _ = written.send(()); + } + } + sink.close().await?; + Ok(()) + }); + ( + ProtocolWriter { + control: control_tx, + binary: binary_tx, + transfer_gate: Arc::new(Mutex::new(())), + }, + task, + ) +} diff --git a/src-tauri/crates/environment-protocol/tests/protocol.rs b/src-tauri/crates/environment-protocol/tests/protocol.rs new file mode 100644 index 00000000..d013189d --- /dev/null +++ b/src-tauri/crates/environment-protocol/tests/protocol.rs @@ -0,0 +1,558 @@ +use std::time::Duration; + +use bytes::{Bytes, BytesMut}; +use environment_protocol::{ + codec, decode, decode_inspection_response, encode, encode_inspection_response, spawn_writer, + AcquirePayloadFromSourceRequest, DecodeError, DocumentWritePreparation, Envelope, + InspectionEntryKind, InspectionErrorCode, InspectionFact, InspectionResponse, + MapHostPathsRequest, MapHostPathsResponse, Message, OpenLocalSourceRequest, PathKind, + SourceScanMode, SourceScanRequest, SourceScanRoot, WireRecord, MAX_FRAME_BYTES, + MAX_PAYLOAD_CHUNK_BYTES, +}; +use futures_util::StreamExt; +use sha2::Digest; +use tokio_util::codec::{Decoder, Encoder, FramedRead}; + +#[test] +fn strict_postcard_wire_format_round_trips_known_literals() { + let shutdown = WireRecord::Control(Envelope { + request_id: 7, + message: Message::Shutdown, + }); + assert_eq!(encode(&shutdown).unwrap(), [0, 7, 7]); + assert_eq!(decode(&[0, 7, 7]).unwrap(), shutdown); + + let chunk = WireRecord::PayloadChunk { + transfer_id: 42, + bytes: vec![0, 255, 1], + }; + assert_eq!(encode(&chunk).unwrap(), [1, 42, 3, 0, 255, 1]); + assert_eq!(decode(&[1, 42, 3, 0, 255, 1]).unwrap(), chunk); +} + +#[test] +fn strict_decoder_rejects_truncated_trailing_and_oversized_frames() { + let encoded = encode(&WireRecord::Control(Envelope { + request_id: 1, + message: Message::Shutdown, + })) + .unwrap(); + + assert!(matches!( + decode(&encoded[..encoded.len() - 1]), + Err(DecodeError::Postcard(_)) + )); + + let mut trailing = encoded; + trailing.push(0); + assert_eq!( + decode(&trailing), + Err(DecodeError::TrailingBytes { count: 1 }) + ); + + let mut framed = BytesMut::new(); + framed.extend_from_slice(&((MAX_FRAME_BYTES + 1) as u32).to_be_bytes()); + framed.resize(MAX_FRAME_BYTES + 5, 0); + assert!(codec().decode(&mut framed).is_err()); +} + +#[test] +fn path_observation_messages_round_trip_without_changing_existing_literals() { + let request = WireRecord::Control(Envelope { + request_id: 8, + message: Message::ObservePath { + path: "/home/alice".to_string(), + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = WireRecord::Control(Envelope { + request_id: 8, + message: Message::PathObserved { + kind: PathKind::Directory, + }, + }); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + assert_eq!( + encode(&WireRecord::Control(Envelope { + request_id: 7, + message: Message::Shutdown, + })) + .unwrap(), + [0, 7, 7] + ); +} + +#[test] +fn host_path_mapping_messages_round_trip_in_input_order() { + let request = WireRecord::Control(Envelope { + request_id: 9, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![ + r"C:\Code\Skill Deck".to_string(), + r"\\server\share\项目".to_string(), + ], + deadline_millis: 10_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = MapHostPathsResponse { + mapped: vec![ + "/mnt/c/Code/Skill Deck".to_string(), + "/mnt/server/share/项目".to_string(), + ], + }; + let encoded = environment_protocol::encode_payload(&response).unwrap(); + assert_eq!( + environment_protocol::decode_payload::(&encoded).unwrap(), + response + ); +} + +#[test] +fn inspection_payload_round_trips_raw_posix_path_bytes() { + let response = InspectionResponse { + facts: vec![InspectionFact { + root_index: 2, + relative_path: vec![b's', b'k', 0x80], + kind: InspectionEntryKind::Symlink, + resolved_target: Some(vec![b'.', b'.', b'/', 0xff]), + content_bytes: vec![0, 255, 1], + truncated: true, + error_code: Some(InspectionErrorCode::ReadLinkFailed), + }], + total_content_bytes: 3, + }; + + let encoded = encode_inspection_response(&response).unwrap(); + assert_eq!(decode_inspection_response(&encoded).unwrap(), response); + + let mut trailing = encoded; + trailing.push(0); + assert!(matches!( + decode_inspection_response(&trailing), + Err(DecodeError::TrailingBytes { count: 1 }) + )); +} + +#[test] +fn source_requests_keep_paths_relative_to_the_owning_handle() { + let open = WireRecord::Control(Envelope { + request_id: 51, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: "/home/alice/skills".to_string(), + }, + }, + }); + assert_eq!(decode(&encode(&open).unwrap()).unwrap(), open); + + let scan = WireRecord::Control(Envelope { + request_id: 52, + message: Message::ScanSource { + request: SourceScanRequest { + source_id: 9, + roots: vec![SourceScanRoot { + relative_path: vec![b's', b'k', 0x80], + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 1024, + aggregate_limit: 4096, + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&scan).unwrap()).unwrap(), scan); +} + +#[test] +fn payload_requests_bind_storage_to_a_source_handle_and_managed_key() { + let request = WireRecord::Control(Envelope { + request_id: 61, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id: 9, + relative_path: b"skills/demo".to_vec(), + session_id: "session-1".to_string(), + payload_name: "payload-demo".to_string(), + deadline_millis: 60_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let upload = WireRecord::Control(Envelope { + request_id: 62, + message: Message::UploadPayloadBlob { + upload_id: 4, + blob_id: "a".repeat(64), + total_bytes: 128, + sha256: format!("sha256:{}", "a".repeat(64)), + }, + }); + assert_eq!(decode(&encode(&upload).unwrap()).unwrap(), upload); +} + +#[test] +fn document_write_messages_round_trip_revision_and_transfer_evidence() { + let request = WireRecord::Control(Envelope { + request_id: 63, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: "/home/alice/.skill-deck/projects.json".to_string(), + expected_revision: Some(format!("sha256:{}", "a".repeat(64))), + total_bytes: 128, + sha256: format!("sha256:{}", "b".repeat(64)), + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&request).unwrap()).unwrap(), request); + + let response = WireRecord::Control(Envelope { + request_id: 63, + message: Message::DocumentWritten { + revision: format!("sha256:{}", "b".repeat(64)), + }, + }); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + let remove = WireRecord::Control(Envelope { + request_id: 64, + message: Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path: "/home/alice/.skill-deck/application.json".to_string(), + expected_revision: Some(format!("sha256:{}", "b".repeat(64))), + deadline_millis: 30_000, + }, + }, + }); + assert_eq!(decode(&encode(&remove).unwrap()).unwrap(), remove); +} + +#[test] +fn mutation_request_round_trips_one_unit_with_payload_handle_and_lock_evidence() { + let request = environment_protocol::MutationUnitRequest { + resource_id: "a".repeat(64), + operation_id: "operation-1".to_string(), + unit_id: "unit-1".to_string(), + initial_marker_json: br#"{"kind":"inProgress"}"#.to_vec(), + entries: vec![environment_protocol::MutationEntry { + destination: "/home/alice/.agents/skills/demo".to_string(), + expected_anchor_device: 1, + expected_anchor_inode: 2, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: environment_protocol::MutationEntryAction::Materialize { payload_id: 9 }, + }], + lock: Some(environment_protocol::MutationLock { + target: "/home/alice/.agents/skills-lock.json".to_string(), + legacy_target: None, + schema: environment_protocol::MutationLockSchema::Global, + entry: environment_protocol::MutationLockEntry::Remove { + key: "demo".to_string(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + + let encoded = environment_protocol::encode_payload(&request).unwrap(); + assert_eq!( + environment_protocol::decode_payload::(&encoded) + .unwrap(), + request + ); +} + +#[test] +fn library_operation_round_trips_opaque_catalog_and_payload_handle() { + let request = environment_protocol::LibraryOperationRequest { + operation_id: "library-operation-1".to_string(), + expected_catalog_revision: Some(format!("sha256:{}", "a".repeat(64))), + catalog_bytes: br#"{"schemaVersion":1}"#.to_vec(), + action: environment_protocol::LibraryOperationAction::CommitMember { + library_id: "library-1".to_string(), + skill_name: "demo".to_string(), + expected_anchor_device: 11, + expected_anchor_inode: 22, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + mutation: environment_protocol::LibraryMemberAction::Upsert { payload_id: 9 }, + }, + deadline_millis: 60_000, + }; + let encoded = environment_protocol::encode_payload(&request).unwrap(); + assert_eq!( + environment_protocol::decode_payload::( + &encoded, + ) + .unwrap(), + request + ); + + let read = WireRecord::Control(Envelope { + request_id: 70, + message: Message::ReadLibraryCatalog { + deadline_millis: 30_000, + }, + }); + assert_eq!(decode(&encode(&read).unwrap()).unwrap(), read); +} + +#[tokio::test] +async fn one_writer_preserves_binary_order_and_acks_written_barriers() { + let (writer_side, reader_side) = tokio::io::duplex(128); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 9, + bytes: vec![1; 1024], + }) + .await + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Progress { + current: 1, + total: 2, + }, + })) + .await + .unwrap(); + + let barrier_writer = writer.clone(); + let barrier = tokio::spawn(async move { + barrier_writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: 9, + message: Message::TransferCompleted { + transfer_id: 9, + total_bytes: 1024, + sha256: "digest".to_string(), + }, + })) + .await + }); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(!barrier.is_finished()); + + let mut records = Vec::new(); + while records.len() < 3 { + let frame = reader.next().await.unwrap().unwrap(); + records.push(decode(&frame).unwrap()); + } + barrier.await.unwrap().unwrap(); + + let chunk_index = records + .iter() + .position(|record| matches!(record, WireRecord::PayloadChunk { transfer_id: 9, .. })) + .unwrap(); + let completion_index = records + .iter() + .position(|record| { + matches!( + record, + WireRecord::Control(Envelope { + message: Message::TransferCompleted { transfer_id: 9, .. }, + .. + }) + ) + }) + .unwrap(); + assert!(chunk_index < completion_index); + drop(writer); + writer_task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn one_transfer_is_chunked_and_bound_to_its_owner_request() { + let (writer_side, reader_side) = tokio::io::duplex(MAX_PAYLOAD_CHUNK_BYTES * 3); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + let payload = vec![0x5a; MAX_PAYLOAD_CHUNK_BYTES * 2 + 7]; + + writer.send_transfer(41, 73, &payload).await.unwrap(); + drop(writer); + + let mut records = Vec::new(); + while let Some(frame) = reader.next().await { + records.push(decode(&frame.unwrap()).unwrap()); + } + writer_task.await.unwrap().unwrap(); + + assert!(matches!( + &records[0], + WireRecord::Control(Envelope { + request_id: 41, + message: Message::BeginTransfer { + transfer_id: 73, + owner_request_id: 41, + total_bytes, + .. + }, + }) if *total_bytes == payload.len() as u64 + )); + let chunks = records[1..records.len() - 1] + .iter() + .map(|record| match record { + WireRecord::PayloadChunk { + transfer_id: 73, + bytes, + } => bytes.as_slice(), + other => panic!("unexpected transfer record: {other:?}"), + }) + .collect::>(); + assert_eq!(chunks.len(), 3); + assert!(chunks + .iter() + .all(|chunk| chunk.len() <= MAX_PAYLOAD_CHUNK_BYTES)); + assert_eq!(chunks.concat(), payload); + assert!(matches!( + records.last().unwrap(), + WireRecord::Control(Envelope { + request_id: 41, + message: Message::TransferCompleted { + transfer_id: 73, + total_bytes, + .. + }, + }) if *total_bytes == payload.len() as u64 + )); +} + +#[tokio::test] +async fn transfer_writer_enforces_the_callers_boundary() { + let (writer_side, _reader_side) = tokio::io::duplex(128); + let (writer, writer_task) = spawn_writer(writer_side); + + let error = writer + .send_transfer_with_limit(1, 1, &[0; 16], 8) + .await + .unwrap_err(); + assert!(matches!( + error, + environment_protocol::WriterError::TransferTooLarge + )); + drop(writer); + writer_task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn reader_transfer_streams_a_declared_blob_without_changing_the_wire_shape() { + let (writer_side, reader_side) = tokio::io::duplex(4096); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + let content = b"streamed-blob"; + let digest = format!("sha256:{:x}", sha2::Sha256::digest(content)); + + writer + .send_reader_transfer_with_limit( + 81, + 82, + std::io::Cursor::new(content), + content.len() as u64, + digest, + 1024, + ) + .await + .unwrap(); + drop(writer); + + let mut records = Vec::new(); + while let Some(frame) = reader.next().await { + records.push(decode(&frame.unwrap()).unwrap()); + } + writer_task.await.unwrap().unwrap(); + assert!(matches!( + records.as_slice(), + [ + WireRecord::Control(Envelope { + message: Message::BeginTransfer { transfer_id: 82, .. }, + .. + }), + WireRecord::PayloadChunk { transfer_id: 82, bytes }, + WireRecord::Control(Envelope { + message: Message::TransferCompleted { transfer_id: 82, .. }, + .. + }) + ] if bytes == content + )); +} + +#[tokio::test] +async fn continuous_control_messages_do_not_starve_binary_records() { + let (writer_side, reader_side) = tokio::io::duplex(4); + let (writer, writer_task) = spawn_writer(writer_side); + let mut reader = FramedRead::new(reader_side, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Progress { + current: 1, + total: 10, + }, + })) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + for request_id in 2..=10 { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Progress { + current: request_id as u32, + total: 10, + }, + })) + .await + .unwrap(); + } + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 99, + bytes: vec![9], + }) + .await + .unwrap(); + + let first = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + let second = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + assert!(matches!(first, WireRecord::Control(_))); + assert!(matches!( + second, + WireRecord::PayloadChunk { + transfer_id: 99, + .. + } + )); + + drop(writer); + while reader.next().await.is_some() {} + writer_task.await.unwrap().unwrap(); +} + +#[test] +fn frame_encoder_accepts_the_largest_valid_record() { + let mut codec = codec(); + let mut output = BytesMut::new(); + codec + .encode(Bytes::from(vec![0; MAX_FRAME_BYTES]), &mut output) + .unwrap(); + assert_eq!(output.len(), MAX_FRAME_BYTES + 4); +} From ce3e24d7a82888aab19f445c6fd80a9bcb71f9f1 Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 1 Sep 2026 23:28:41 +0800 Subject: [PATCH 02/27] refactor(environment): extract the shared execution engine --- .../crates/environment-engine/Cargo.toml | 15 + .../environment-engine/src/directory.rs | 164 ++++ .../crates/environment-engine/src/document.rs | 306 ++++++++ .../crates/environment-engine/src/entry.rs | 139 ++++ .../environment-engine/src/inspection.rs | 282 +++++++ .../crates/environment-engine/src/lib.rs | 16 + .../crates/environment-engine/src/library.rs | 509 +++++++++++++ .../environment-engine/src/linux_mutation.rs | 711 ++++++++++++++++++ .../crates/environment-engine/src/lock.rs | 356 +++++++++ .../crates/environment-engine/src/manifest.rs | 163 ++++ .../crates/environment-engine/src/path.rs | 185 +++++ .../crates/environment-engine/src/payload.rs | 647 ++++++++++++++++ .../environment-engine/src/projection.rs | 134 ++++ .../src/source_inventory.rs | 427 +++++++++++ .../environment-engine/tests/document.rs | 117 +++ .../environment-engine/tests/inspection.rs | 190 +++++ .../environment-engine/tests/library.rs | 239 ++++++ .../tests/linux_mutation.rs | 378 ++++++++++ .../crates/environment-engine/tests/lock.rs | 81 ++ .../tests/path_and_directory.rs | 91 +++ .../environment-engine/tests/payload.rs | 125 +++ .../environment-engine/tests/planning.rs | 81 ++ .../tests/source_inventory.rs | 91 +++ 23 files changed, 5447 insertions(+) create mode 100644 src-tauri/crates/environment-engine/Cargo.toml create mode 100644 src-tauri/crates/environment-engine/src/directory.rs create mode 100644 src-tauri/crates/environment-engine/src/document.rs create mode 100644 src-tauri/crates/environment-engine/src/entry.rs create mode 100644 src-tauri/crates/environment-engine/src/inspection.rs create mode 100644 src-tauri/crates/environment-engine/src/lib.rs create mode 100644 src-tauri/crates/environment-engine/src/library.rs create mode 100644 src-tauri/crates/environment-engine/src/linux_mutation.rs create mode 100644 src-tauri/crates/environment-engine/src/lock.rs create mode 100644 src-tauri/crates/environment-engine/src/manifest.rs create mode 100644 src-tauri/crates/environment-engine/src/path.rs create mode 100644 src-tauri/crates/environment-engine/src/payload.rs create mode 100644 src-tauri/crates/environment-engine/src/projection.rs create mode 100644 src-tauri/crates/environment-engine/src/source_inventory.rs create mode 100644 src-tauri/crates/environment-engine/tests/document.rs create mode 100644 src-tauri/crates/environment-engine/tests/inspection.rs create mode 100644 src-tauri/crates/environment-engine/tests/library.rs create mode 100644 src-tauri/crates/environment-engine/tests/linux_mutation.rs create mode 100644 src-tauri/crates/environment-engine/tests/lock.rs create mode 100644 src-tauri/crates/environment-engine/tests/path_and_directory.rs create mode 100644 src-tauri/crates/environment-engine/tests/payload.rs create mode 100644 src-tauri/crates/environment-engine/tests/planning.rs create mode 100644 src-tauri/crates/environment-engine/tests/source_inventory.rs diff --git a/src-tauri/crates/environment-engine/Cargo.toml b/src-tauri/crates/environment-engine/Cargo.toml new file mode 100644 index 00000000..415baaef --- /dev/null +++ b/src-tauri/crates/environment-engine/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "environment-engine" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10.9" +unicode-normalization = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/crates/environment-engine/src/directory.rs b/src-tauri/crates/environment-engine/src/directory.rs new file mode 100644 index 00000000..fe481c5c --- /dev/null +++ b/src-tauri/crates/environment-engine/src/directory.rs @@ -0,0 +1,164 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountRequest { + pub paths: Vec, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountFact { + pub path: PathBuf, + pub observed_count: Option, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryCountResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryListRequest { + pub path: PathBuf, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryListResponse { + pub names: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectoryCountError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for DirectoryCountError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => { + formatter.write_str("Linux directory count is unavailable") + } + Self::InvalidRequest => formatter.write_str("invalid bounded directory count request"), + Self::Cancelled => formatter.write_str("directory count was cancelled"), + } + } +} + +impl std::error::Error for DirectoryCountError {} + +pub fn count_entries( + request: &DirectoryCountRequest, +) -> Result { + count_entries_with_cancel(request, || false) +} + +pub fn list_child_directories( + request: &DirectoryListRequest, +) -> Result { + list_child_directories_with_cancel(request, || false) +} + +pub fn list_child_directories_with_cancel( + request: &DirectoryListRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if !request.path.is_absolute() || request.limit == 0 { + return Err(DirectoryCountError::InvalidRequest); + } + list_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn list_platform( + _request: &DirectoryListRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DirectoryCountError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn list_platform( + request: &DirectoryListRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut names = match fs::read_dir(&request.path) { + Ok(entries) => entries + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .map(|entry| PathBuf::from(entry.file_name())) + .collect::>(), + Err(_) => Vec::new(), + }; + if is_cancelled() { + return Err(DirectoryCountError::Cancelled); + } + names.sort(); + let truncated = names.len() > request.limit as usize; + names.truncate(request.limit as usize); + Ok(DirectoryListResponse { names, truncated }) +} + +pub fn count_entries_with_cancel( + request: &DirectoryCountRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() + || request.limit == 0 + || request.paths.iter().any(|path| !path.is_absolute()) + { + return Err(DirectoryCountError::InvalidRequest); + } + count_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn count_platform( + _request: &DirectoryCountRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DirectoryCountError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn count_platform( + request: &DirectoryCountRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut facts = Vec::with_capacity(request.paths.len()); + for path in &request.paths { + if is_cancelled() { + return Err(DirectoryCountError::Cancelled); + } + let observed = fs::read_dir(path).ok().and_then(|entries| { + let mut count = 0u32; + for entry in entries.take(request.limit as usize + 1) { + if is_cancelled() || entry.is_err() { + return None; + } + count += 1; + } + Some((count.min(request.limit), count > request.limit)) + }); + facts.push(DirectoryCountFact { + path: path.clone(), + observed_count: observed.map(|(count, _)| count), + truncated: observed.is_some_and(|(_, truncated)| truncated), + }); + } + Ok(DirectoryCountResponse { facts }) +} diff --git a/src-tauri/crates/environment-engine/src/document.rs b/src-tauri/crates/environment-engine/src/document.rs new file mode 100644 index 00000000..300d231a --- /dev/null +++ b/src-tauri/crates/environment-engine/src/document.rs @@ -0,0 +1,306 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentRequest { + pub queries: Vec, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentQuery { + pub path: PathBuf, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocumentState { + Missing, + NotFile, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentFact { + pub path: PathBuf, + pub state: DocumentState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for DocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux document read is unavailable"), + Self::InvalidRequest => formatter.write_str("invalid bounded document read request"), + Self::Cancelled => formatter.write_str("document read was cancelled"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentWriteError { + UnsupportedPlatform, + Io, + Conflict, + InvalidTarget, +} + +impl fmt::Display for DocumentWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux document write is unavailable"), + Self::Io => formatter.write_str("document write failed"), + Self::Conflict => formatter.write_str("document changed since it was read"), + Self::InvalidTarget => formatter.write_str("document target is not a regular file"), + } + } +} + +pub fn write_document_atomic( + path: &std::path::Path, + expected_revision: Option<&str>, + bytes: &[u8], +) -> Result { + write_document_platform(path, expected_revision, bytes) +} + +pub fn remove_document_if_revision( + path: &std::path::Path, + expected_revision: Option<&str>, +) -> Result<(), DocumentWriteError> { + remove_document_platform(path, expected_revision) +} + +#[cfg(not(target_os = "linux"))] +fn write_document_platform( + _path: &std::path::Path, + _expected_revision: Option<&str>, + _bytes: &[u8], +) -> Result { + Err(DocumentWriteError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn remove_document_platform( + _path: &std::path::Path, + _expected_revision: Option<&str>, +) -> Result<(), DocumentWriteError> { + Err(DocumentWriteError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn write_document_platform( + path: &std::path::Path, + expected_revision: Option<&str>, + bytes: &[u8], +) -> Result { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + if !path.is_absolute() || path.file_name().is_none() { + return Err(DocumentWriteError::InvalidTarget); + } + let current = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DocumentWriteError::InvalidTarget) + } + Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(DocumentWriteError::Io), + }; + let current_revision = current.as_deref().map(document_revision); + if current_revision.as_deref() != expected_revision { + return Err(DocumentWriteError::Conflict); + } + let parent = path.parent().ok_or(DocumentWriteError::InvalidTarget)?; + fs::create_dir_all(parent).map_err(|_| DocumentWriteError::Io)?; + let temporary = parent.join(format!(".skill-deck-document-{}", std::process::id())); + if fs::symlink_metadata(&temporary).is_ok() { + return Err(DocumentWriteError::Io); + } + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| DocumentWriteError::Io)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)) + .map_err(|_| DocumentWriteError::Io)?; + file.write_all(bytes).map_err(|_| DocumentWriteError::Io)?; + file.sync_all().map_err(|_| DocumentWriteError::Io)?; + let latest = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DocumentWriteError::InvalidTarget) + } + Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(DocumentWriteError::Io), + }; + if latest.as_deref().map(document_revision).as_deref() != expected_revision { + return Err(DocumentWriteError::Conflict); + } + fs::rename(&temporary, path).map_err(|_| DocumentWriteError::Io)?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| DocumentWriteError::Io)?; + Ok::<_, DocumentWriteError>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result?; + Ok(document_revision(bytes)) +} + +#[cfg(target_os = "linux")] +fn remove_document_platform( + path: &std::path::Path, + expected_revision: Option<&str>, +) -> Result<(), DocumentWriteError> { + if !path.is_absolute() || path.file_name().is_none() { + return Err(DocumentWriteError::InvalidTarget); + } + let current = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(DocumentWriteError::InvalidTarget) + } + Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(DocumentWriteError::Io), + }; + if current.as_deref().map(document_revision).as_deref() != expected_revision { + return Err(DocumentWriteError::Conflict); + } + if current.is_none() { + return Ok(()); + } + let latest = fs::read(path).map_err(|_| DocumentWriteError::Io)?; + if document_revision(&latest) != expected_revision.unwrap_or_default() { + return Err(DocumentWriteError::Conflict); + } + fs::remove_file(path).map_err(|_| DocumentWriteError::Io)?; + let parent = path.parent().ok_or(DocumentWriteError::InvalidTarget)?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| DocumentWriteError::Io) +} + +#[cfg(target_os = "linux")] +fn document_revision(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +impl std::error::Error for DocumentError {} +impl std::error::Error for DocumentWriteError {} + +pub fn read_documents(request: &DocumentRequest) -> Result { + read_documents_with_cancel(request, || false) +} + +pub fn read_documents_with_cancel( + request: &DocumentRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.aggregate_limit == 0 + || request + .queries + .iter() + .any(|query| !query.path.is_absolute() || query.limit == 0) + { + return Err(DocumentError::InvalidRequest); + } + read_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn read_platform( + _request: &DocumentRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(DocumentError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn read_platform( + request: &DocumentRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut total_content_bytes = 0usize; + let mut facts = Vec::with_capacity(request.queries.len()); + for query in &request.queries { + if is_cancelled() { + return Err(DocumentError::Cancelled); + } + let metadata = match fs::metadata(&query.path) { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => { + facts.push(fact(query.path.clone(), DocumentState::NotFile, false)); + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + facts.push(fact(query.path.clone(), DocumentState::Missing, false)); + continue; + } + Err(_) => { + facts.push(fact(query.path.clone(), DocumentState::Unreadable, false)); + continue; + } + }; + let remaining = (request.aggregate_limit as usize).saturating_sub(total_content_bytes); + let limit = remaining.min(query.limit as usize); + let mut bytes = Vec::new(); + let state = match fs::File::open(&query.path) { + Ok(file) => { + if file.take(limit as u64).read_to_end(&mut bytes).is_ok() { + total_content_bytes += bytes.len(); + DocumentState::Bytes(bytes) + } else { + DocumentState::Unreadable + } + } + Err(_) => DocumentState::Unreadable, + }; + facts.push(fact( + query.path.clone(), + state, + metadata.len() > limit as u64, + )); + } + Ok(DocumentResponse { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn fact(path: PathBuf, state: DocumentState, truncated: bool) -> DocumentFact { + DocumentFact { + path, + state, + truncated, + } +} diff --git a/src-tauri/crates/environment-engine/src/entry.rs b/src-tauri/crates/environment-engine/src/entry.rs new file mode 100644 index 00000000..97e068d4 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/entry.rs @@ -0,0 +1,139 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryRequest { + pub paths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Missing, + File, + Directory, + Symlink, + BrokenLink, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryMetadata { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub size: u64, + pub mtime_seconds: i64, + pub mtime_nanos: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryFact { + pub kind: EntryKind, + pub metadata: Option, + pub link_target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryResponse { + pub facts: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + Cancelled, +} + +impl fmt::Display for EntryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for EntryError {} + +pub fn inspect_entries(request: &EntryRequest) -> Result { + inspect_entries_with_cancel(request, || false) +} + +pub fn inspect_entries_with_cancel( + request: &EntryRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() || request.paths.iter().any(|path| !path.is_absolute()) { + return Err(EntryError::InvalidRequest); + } + inspect_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &EntryRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(EntryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &EntryRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + use std::os::unix::fs::MetadataExt; + + let mut facts = Vec::with_capacity(request.paths.len()); + for path in &request.paths { + if is_cancelled() { + return Err(EntryError::Cancelled); + } + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + facts.push(EntryFact { + kind: EntryKind::Missing, + metadata: None, + link_target: None, + }); + continue; + } + Err(_) => return Err(EntryError::Unavailable), + }; + let file_type = metadata.file_type(); + let (kind, link_target) = if file_type.is_symlink() { + let target = fs::read_link(path).map_err(|_| EntryError::Unavailable)?; + let kind = match fs::metadata(path) { + Ok(_) => EntryKind::Symlink, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => EntryKind::BrokenLink, + Err(_) => return Err(EntryError::Unavailable), + }; + (kind, Some(target)) + } else if metadata.is_dir() { + (EntryKind::Directory, None) + } else if metadata.is_file() { + (EntryKind::File, None) + } else { + (EntryKind::Other, None) + }; + facts.push(EntryFact { + kind, + metadata: Some(EntryMetadata { + device: metadata.dev(), + inode: metadata.ino(), + mode: metadata.mode(), + size: metadata.size(), + mtime_seconds: metadata.mtime(), + mtime_nanos: metadata.mtime_nsec(), + }), + link_target, + }); + } + Ok(EntryResponse { facts }) +} diff --git a/src-tauri/crates/environment-engine/src/inspection.rs b/src-tauri/crates/environment-engine/src/inspection.rs new file mode 100644 index 00000000..e9d74126 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/inspection.rs @@ -0,0 +1,282 @@ +use std::fmt; +use std::path::PathBuf; +#[cfg(target_os = "linux")] +use std::{fs, io::Read, path::Path}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionRequest { + pub roots: Vec, + pub per_file_limit: u32, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionRoot { + pub path: PathBuf, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCode { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathFact { + pub root_index: u32, + pub relative_path: PathBuf, + pub kind: EntryKind, + pub resolved_target: Option, + pub content_bytes: Vec, + pub truncated: bool, + pub error_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InspectionSnapshot { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InspectionError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for InspectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str("Linux inspection is unavailable"), + Self::InvalidRequest => formatter.write_str("invalid bounded inspection request"), + Self::Cancelled => formatter.write_str("inspection was cancelled"), + } + } +} + +impl std::error::Error for InspectionError {} + +pub fn inspect(request: &InspectionRequest) -> Result { + inspect_with_cancel(request, || false) +} + +pub fn inspect_with_cancel( + request: &InspectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate(request)?; + inspect_platform(request, &is_cancelled) +} + +fn validate(request: &InspectionRequest) -> Result<(), InspectionError> { + if request.roots.is_empty() + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.roots.len() > u32::MAX as usize + { + return Err(InspectionError::InvalidRequest); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &InspectionRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(InspectionError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &InspectionRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut facts = Vec::new(); + let mut total_content_bytes = 0usize; + + for (root_index, root) in request.roots.iter().enumerate() { + ensure_not_cancelled(is_cancelled)?; + let root_fact = inspect_path( + &root.path, + root_index as u32, + PathBuf::new(), + false, + request, + &mut total_content_bytes, + ); + let root_is_directory = root_fact.kind == EntryKind::Directory; + facts.push(root_fact); + + if root.stat_only || !root_is_directory { + continue; + } + + let entries = match fs::read_dir(&root.path) { + Ok(entries) => entries, + Err(_) => { + if let Some(root_fact) = facts.last_mut() { + root_fact.error_code = Some(ErrorCode::PathUnavailable); + } + continue; + } + }; + let mut children = entries.filter_map(Result::ok).collect::>(); + children.sort_by_key(fs::DirEntry::file_name); + + for child in children { + ensure_not_cancelled(is_cancelled)?; + let relative_path = PathBuf::from(child.file_name()); + let child_path = child.path(); + let child_fact = inspect_path( + &child_path, + root_index as u32, + relative_path.clone(), + false, + request, + &mut total_content_bytes, + ); + let can_contain_skill = + matches!(child_fact.kind, EntryKind::Directory | EntryKind::Symlink); + facts.push(child_fact); + + if can_contain_skill { + let skill_path = child_path.join("SKILL.md"); + if fs::symlink_metadata(&skill_path).is_ok() { + facts.push(inspect_path( + &skill_path, + root_index as u32, + relative_path.join("SKILL.md"), + true, + request, + &mut total_content_bytes, + )); + } + } + } + } + + Ok(InspectionSnapshot { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), InspectionError> { + if is_cancelled() { + Err(InspectionError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn inspect_path( + path: &Path, + root_index: u32, + relative_path: PathBuf, + read_content: bool, + request: &InspectionRequest, + total_content_bytes: &mut usize, +) -> PathFact { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return empty_fact(root_index, relative_path, EntryKind::Missing, None); + } + Err(_) => { + return empty_fact( + root_index, + relative_path, + EntryKind::Other, + Some(ErrorCode::PathUnavailable), + ); + } + }; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() { + EntryKind::Symlink + } else if file_type.is_file() { + EntryKind::File + } else if file_type.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }; + let (resolved_target, mut error_code) = if kind == EntryKind::Symlink { + match fs::read_link(path) { + Ok(target) => (Some(target), None), + Err(_) => (None, Some(ErrorCode::ReadLinkFailed)), + } + } else { + (None, None) + }; + let mut content_bytes = Vec::new(); + let mut truncated = false; + + if kind == EntryKind::File && read_content { + let remaining = (request.aggregate_limit as usize).saturating_sub(*total_content_bytes); + let limit = remaining.min(request.per_file_limit as usize); + match fs::File::open(path) { + Ok(file) => { + if file + .take(limit as u64) + .read_to_end(&mut content_bytes) + .is_err() + { + content_bytes.clear(); + error_code = Some(ErrorCode::ReadFailed); + } else { + truncated = metadata.len() > content_bytes.len() as u64; + *total_content_bytes += content_bytes.len(); + } + } + Err(_) => error_code = Some(ErrorCode::ReadFailed), + } + } + + PathFact { + root_index, + relative_path, + kind, + resolved_target, + content_bytes, + truncated, + error_code, + } +} + +#[cfg(target_os = "linux")] +fn empty_fact( + root_index: u32, + relative_path: PathBuf, + kind: EntryKind, + error_code: Option, +) -> PathFact { + PathFact { + root_index, + relative_path, + kind, + resolved_target: None, + content_bytes: Vec::new(), + truncated: false, + error_code, + } +} diff --git a/src-tauri/crates/environment-engine/src/lib.rs b/src-tauri/crates/environment-engine/src/lib.rs new file mode 100644 index 00000000..6634f754 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/lib.rs @@ -0,0 +1,16 @@ +#![forbid(unsafe_code)] + +//! Shared Linux filesystem mechanics used by Native Linux and the WSL Worker. + +pub mod directory; +pub mod document; +pub mod entry; +pub mod inspection; +pub mod library; +pub mod linux_mutation; +pub mod lock; +pub mod manifest; +pub mod path; +pub mod payload; +pub mod projection; +pub mod source_inventory; diff --git a/src-tauri/crates/environment-engine/src/library.rs b/src-tauri/crates/environment-engine/src/library.rs new file mode 100644 index 00000000..1853d360 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/library.rs @@ -0,0 +1,509 @@ +use std::fmt; +use std::path::{Path, PathBuf}; + +use crate::linux_mutation::ParentIdentity; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogSnapshot { + pub bytes: Option>, + pub revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogWrite { + pub expected_revision: Option, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetExpectation { + pub parent: ParentIdentity, + pub fingerprint: String, + pub content_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContentAction { + Upsert { payload_root: PathBuf }, + Delete, + DeleteIfPresent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryCommit { + pub root: PathBuf, + pub operation_id: String, + pub destination: PathBuf, + pub expected_target: TargetExpectation, + pub content: ContentAction, + pub catalog: CatalogWrite, +} + +#[derive(Debug)] +pub enum LibraryError { + UnsupportedPlatform, + InvalidRequest, + StaleTarget, + InvalidPayload, + RecoveryIncomplete, + Io(std::io::Error), +} + +impl fmt::Display for LibraryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LibraryError {} + +impl From for LibraryError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +pub fn read_catalog(root: &Path) -> Result { + read_catalog_platform(root) +} + +pub fn commit(request: LibraryCommit) -> Result<(), LibraryError> { + commit_platform(request) +} + +pub fn write_catalog( + root: &Path, + library_ids: &[String], + catalog: CatalogWrite, +) -> Result { + write_catalog_platform(root, library_ids, catalog) +} + +#[cfg(not(target_os = "linux"))] +fn read_catalog_platform(_root: &Path) -> Result { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn commit_platform(_request: LibraryCommit) -> Result<(), LibraryError> { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(not(target_os = "linux"))] +fn write_catalog_platform( + _root: &Path, + _library_ids: &[String], + _catalog: CatalogWrite, +) -> Result { + Err(LibraryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn read_catalog_platform(root: &Path) -> Result { + let catalog = read_optional(&root.join("catalog.json"))?; + let current_revision = catalog.as_deref().map(revision); + recover(root, current_revision.as_deref())?; + let bytes = read_optional(&root.join("catalog.json"))?; + Ok(CatalogSnapshot { + revision: bytes.as_deref().map(revision), + bytes, + }) +} + +#[cfg(target_os = "linux")] +fn commit_platform(request: LibraryCommit) -> Result<(), LibraryError> { + validate_request(&request)?; + let current = read_catalog_platform(&request.root)?; + if current.revision != request.catalog.expected_revision { + return Err(LibraryError::StaleTarget); + } + validate_target(&request.destination, &request.expected_target)?; + + let transaction = request + .root + .join(".transactions") + .join(&request.operation_id); + if std::fs::symlink_metadata(&transaction).is_ok() { + return Err(LibraryError::StaleTarget); + } + std::fs::create_dir_all(&transaction)?; + let stage = transaction.join("stage"); + let backup = transaction.join("backup"); + write_transaction( + &transaction, + &request.destination, + matches!(request.content, ContentAction::Upsert { .. }), + "preparing", + None, + )?; + + let new_revision = revision(&request.catalog.bytes); + let result = (|| { + match &request.content { + ContentAction::Upsert { payload_root } => { + materialize_payload(payload_root, &stage)?; + verify_materialized(payload_root, &stage)?; + write_state(&transaction.join("phase"), "staged")?; + } + ContentAction::Delete => { + if !request.destination.is_dir() { + return Err(LibraryError::StaleTarget); + } + } + ContentAction::DeleteIfPresent => { + if std::fs::symlink_metadata(&request.destination) + .is_ok_and(|metadata| !metadata.is_dir() || metadata.file_type().is_symlink()) + { + return Err(LibraryError::StaleTarget); + } + } + } + validate_target(&request.destination, &request.expected_target)?; + if std::fs::symlink_metadata(&request.destination).is_ok() { + write_state(&transaction.join("phase"), "backedUp")?; + std::fs::rename(&request.destination, &backup)?; + } + write_state(&transaction.join("phase"), "activated")?; + if matches!(request.content, ContentAction::Upsert { .. }) { + std::fs::create_dir_all( + request + .destination + .parent() + .ok_or(LibraryError::InvalidRequest)?, + )?; + std::fs::rename(&stage, &request.destination)?; + if let ContentAction::Upsert { payload_root } = &request.content { + verify_materialized(payload_root, &request.destination)?; + } + } + write_state(&transaction.join("expected-catalog-hash"), &new_revision)?; + write_state(&transaction.join("phase"), "catalogPrepared")?; + write_catalog_document( + &request.root.join("catalog.json"), + request.catalog.expected_revision.as_deref(), + &request.catalog.bytes, + )?; + write_state(&transaction.join("phase"), "catalogCommitted")?; + remove_any(&backup)?; + std::fs::remove_dir_all(&transaction)?; + Ok(()) + })(); + if let Err(error) = result { + let current = read_optional(&request.root.join("catalog.json"))?; + let current_revision = current.as_deref().map(revision); + recover(&request.root, current_revision.as_deref())?; + if current_revision.as_deref() == Some(new_revision.as_str()) { + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_target(destination: &Path, expected: &TargetExpectation) -> Result<(), LibraryError> { + let target = crate::projection::project_targets(&crate::projection::ProjectionRequest { + destinations: vec![destination.to_path_buf()], + }) + .map_err(|_| LibraryError::StaleTarget)? + .targets + .pop() + .ok_or(LibraryError::StaleTarget)?; + if target.anchor_device != expected.parent.device + || target.anchor_inode != expected.parent.inode + || crate::linux_mutation::fingerprint_path(&target.physical_destination).as_deref() + != Ok(expected.fingerprint.as_str()) + { + return Err(LibraryError::StaleTarget); + } + if let Some(expected_hash) = &expected.content_hash { + if crate::linux_mutation::content_hash_path(&target.physical_destination).as_deref() + != Ok(expected_hash.as_str()) + { + return Err(LibraryError::StaleTarget); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn write_catalog_platform( + root: &Path, + library_ids: &[String], + catalog: CatalogWrite, +) -> Result { + if !root.is_absolute() + || catalog.bytes.is_empty() + || library_ids.iter().any(|id| !valid_component(id)) + { + return Err(LibraryError::InvalidRequest); + } + let current = read_catalog_platform(root)?; + if current.revision != catalog.expected_revision { + return Err(LibraryError::StaleTarget); + } + let mut created = Vec::new(); + for library_id in library_ids { + let library = root.join("libraries").join(library_id); + if std::fs::symlink_metadata(&library).is_err() { + std::fs::create_dir_all(library.join("skills"))?; + created.push(library); + } + } + let result = write_catalog_document( + &root.join("catalog.json"), + catalog.expected_revision.as_deref(), + &catalog.bytes, + ); + if result.is_err() { + for library in created { + let _ = std::fs::remove_dir_all(library); + } + } + result?; + Ok(revision(&catalog.bytes)) +} + +#[cfg(target_os = "linux")] +fn validate_request(request: &LibraryCommit) -> Result<(), LibraryError> { + let managed = request.root.join("libraries"); + if !request.root.is_absolute() + || !request.destination.starts_with(&managed) + || request.operation_id.is_empty() + || request.operation_id.contains(['/', '\\', '\0']) + || request.catalog.bytes.is_empty() + { + return Err(LibraryError::InvalidRequest); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn valid_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + +#[cfg(target_os = "linux")] +fn write_transaction( + transaction: &Path, + destination: &Path, + desired_presence: bool, + phase: &str, + expected_catalog_hash: Option<&str>, +) -> Result<(), LibraryError> { + write_state( + &transaction.join("destination"), + destination.to_str().ok_or(LibraryError::InvalidRequest)?, + )?; + write_state( + &transaction.join("desired-presence"), + if desired_presence { "1" } else { "0" }, + )?; + if let Some(expected) = expected_catalog_hash { + write_state(&transaction.join("expected-catalog-hash"), expected)?; + } + write_state(&transaction.join("phase"), phase) +} + +#[cfg(target_os = "linux")] +fn write_state(path: &Path, value: &str) -> Result<(), LibraryError> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let parent = path.parent().ok_or(LibraryError::InvalidRequest)?; + std::fs::create_dir_all(parent)?; + let temporary = path.with_extension(format!("tmp.{}", std::process::id())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))?; + let result = (|| { + file.write_all(value.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + std::fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map_err(LibraryError::from) +} + +#[cfg(target_os = "linux")] +fn write_catalog_document( + path: &Path, + expected_revision: Option<&str>, + bytes: &[u8], +) -> Result<(), LibraryError> { + crate::document::write_document_atomic(path, expected_revision, bytes) + .map(|_| ()) + .map_err(|error| match error { + crate::document::DocumentWriteError::Conflict => LibraryError::StaleTarget, + crate::document::DocumentWriteError::InvalidTarget => LibraryError::InvalidRequest, + crate::document::DocumentWriteError::UnsupportedPlatform => { + LibraryError::UnsupportedPlatform + } + crate::document::DocumentWriteError::Io => { + LibraryError::Io(std::io::Error::other("failed to write Library catalog")) + } + }) +} + +#[cfg(target_os = "linux")] +fn materialize_payload(payload_root: &Path, destination: &Path) -> Result<(), LibraryError> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let manifest = + crate::payload::verify_payload(payload_root).map_err(|_| LibraryError::InvalidPayload)?; + std::fs::create_dir(destination)?; + for entry in manifest.entries { + let target = destination.join(&entry.relative_path); + match entry.kind { + crate::payload::PayloadEntryKind::Directory => std::fs::create_dir_all(&target)?, + crate::payload::PayloadEntryKind::File => { + std::fs::create_dir_all(target.parent().ok_or(LibraryError::InvalidPayload)?)?; + let blob_id = entry.blob_id.ok_or(LibraryError::InvalidPayload)?; + let mut input = crate::payload::read_blob(payload_root, &blob_id) + .map_err(|_| LibraryError::InvalidPayload)? + .ok_or(LibraryError::InvalidPayload)?; + let mut output = std::fs::File::create(&target)?; + std::io::copy(&mut input, &mut output)?; + output.flush()?; + std::fs::set_permissions( + &target, + std::fs::Permissions::from_mode(if entry.executable { 0o755 } else { 0o644 }), + )?; + } + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn verify_materialized(payload_root: &Path, destination: &Path) -> Result<(), LibraryError> { + let manifest = + crate::payload::verify_payload(payload_root).map_err(|_| LibraryError::InvalidPayload)?; + for entry in manifest.entries { + let target = destination.join(&entry.relative_path); + let valid = match entry.kind { + crate::payload::PayloadEntryKind::Directory => target.is_dir(), + crate::payload::PayloadEntryKind::File => { + target.is_file() + && entry.content_hash.as_deref().is_some_and(|expected| { + std::fs::read(&target) + .ok() + .is_some_and(|bytes| revision_raw(&bytes) == expected) + }) + } + }; + if !valid { + return Err(LibraryError::InvalidPayload); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn recover(root: &Path, catalog_revision: Option<&str>) -> Result<(), LibraryError> { + let transactions = root.join(".transactions"); + let Ok(entries) = std::fs::read_dir(&transactions) else { + return Ok(()); + }; + for entry in entries { + let transaction = entry?.path(); + if !transaction.is_dir() { + continue; + } + let destination = read_required_text(&transaction.join("destination"))?; + let destination = PathBuf::from(destination); + if !destination.starts_with(root.join("libraries")) { + return Err(LibraryError::RecoveryIncomplete); + } + let phase = read_required_text(&transaction.join("phase"))?; + let desired_presence = + match read_required_text(&transaction.join("desired-presence"))?.as_str() { + "1" => true, + "0" => false, + _ => return Err(LibraryError::RecoveryIncomplete), + }; + let stage = transaction.join("stage"); + let backup = transaction.join("backup"); + match phase.as_str() { + "preparing" | "staged" => {} + "backedUp" if !destination.exists() && backup.is_dir() && stage.exists() => { + std::fs::rename(&backup, &destination)?; + } + "backedUp" if destination.exists() && !backup.exists() => {} + "backedUp" if destination.exists() && backup.is_dir() && !stage.exists() => {} + "activated" => rollback(&destination, &backup)?, + "catalogPrepared" if destination.exists() == desired_presence => { + let expected = read_required_text(&transaction.join("expected-catalog-hash"))?; + if !same_revision(catalog_revision, &expected) { + rollback(&destination, &backup)?; + } + } + "catalogCommitted" if destination.exists() == desired_presence => {} + _ => return Err(LibraryError::RecoveryIncomplete), + } + remove_any(&stage)?; + remove_any(&backup)?; + std::fs::remove_dir_all(&transaction)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn rollback(destination: &Path, backup: &Path) -> Result<(), LibraryError> { + remove_any(destination)?; + if backup.exists() { + std::fs::rename(backup, destination)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn remove_any(path: &Path) -> Result<(), LibraryError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path)? + } + Ok(_) => std::fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_required_text(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|_| LibraryError::RecoveryIncomplete) +} + +#[cfg(target_os = "linux")] +fn read_optional(path: &Path) -> Result>, LibraryError> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +#[cfg(target_os = "linux")] +fn revision(bytes: &[u8]) -> String { + format!("sha256:{}", revision_raw(bytes)) +} + +#[cfg(target_os = "linux")] +fn revision_raw(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(target_os = "linux")] +fn same_revision(actual: Option<&str>, expected: &str) -> bool { + actual.map(|value| value.strip_prefix("sha256:").unwrap_or(value)) + == Some(expected.strip_prefix("sha256:").unwrap_or(expected)) +} diff --git a/src-tauri/crates/environment-engine/src/linux_mutation.rs b/src-tauri/crates/environment-engine/src/linux_mutation.rs new file mode 100644 index 00000000..4ba10fed --- /dev/null +++ b/src-tauri/crates/environment-engine/src/linux_mutation.rs @@ -0,0 +1,711 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +use crate::entry::{inspect_entries, EntryKind, EntryRequest}; +use crate::manifest::{build_manifest, ManifestKind, ManifestRequest, ManifestResponse}; +#[cfg(target_os = "linux")] +use crate::payload::read_blob; +use crate::payload::{verify_payload, PayloadEntryKind, PayloadManifest}; +#[cfg(target_os = "linux")] +use crate::projection::{project_targets, ProjectionRequest}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParentIdentity { + pub device: u64, + pub inode: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryAction { + Keep, + Materialize { payload_root: PathBuf }, + Symlink { target: PathBuf }, + Remove, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryIntent { + pub destination: PathBuf, + pub expected_parent: ParentIdentity, + pub expected_fingerprint: String, + pub expected_content_hash: Option, + pub action: EntryAction, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryEntry { + pub destination: PathBuf, + pub backup: PathBuf, + pub expected_present: bool, + pub original_fingerprint: String, +} + +#[derive(Debug)] +struct StagedEntry { + intent: EntryIntent, + parent_identity: Option, + stage: Option, + backup: PathBuf, + backup_created: bool, + installed: bool, +} + +#[derive(Debug)] +pub struct StagedMutation { + entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationError { + UnsupportedPlatform, + InvalidRequest, + StaleTarget, + InvalidPayload, + VerificationFailed, + Cancelled, + RestoreFailed { message: String }, + Io { message: String }, +} + +impl fmt::Display for MutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for MutationError {} + +impl From for MutationError { + fn from(error: std::io::Error) -> Self { + Self::Io { + message: error.to_string(), + } + } +} + +pub fn parent_identity(path: &Path) -> Result { + parent_identity_platform(path) +} + +pub fn fingerprint_path(path: &Path) -> Result { + let fact = inspect_entries(&EntryRequest { + paths: vec![path.to_path_buf()], + }) + .map_err(|_| MutationError::Io { + message: format!("failed to inspect {}", path.display()), + })? + .facts + .into_iter() + .next() + .ok_or(MutationError::InvalidRequest)?; + if fact.kind == EntryKind::Missing { + return Ok("entry-v1-missing".to_string()); + } + let metadata = fact.metadata.ok_or(MutationError::InvalidRequest)?; + let values = [ + metadata.device.to_string(), + metadata.inode.to_string(), + format!("{:x}", metadata.mode), + metadata.size.to_string(), + metadata.mtime_seconds.to_string(), + format!("{:09}", metadata.mtime_nanos), + ]; + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-wsl-entry-v1\0"); + for value in values { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + if let Some(target) = fact.link_target { + hasher.update(target.as_os_str().as_encoded_bytes()); + } + Ok(format!("entry-v1-{:x}", hasher.finalize())) +} + +pub fn validate_intents(intents: &[EntryIntent], is_cancelled: F) -> Result<(), MutationError> +where + F: Fn() -> bool, +{ + validate_intents_platform(intents, &is_cancelled) +} + +impl StagedMutation { + pub fn stage( + operation_id: &str, + intents: Vec, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + stage_platform(operation_id, intents, &is_cancelled) + } + + pub fn recheck(&self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + for entry in &self.entries { + check_cancelled(&is_cancelled)?; + recheck_entry(entry)?; + } + Ok(()) + } + + pub fn swap(&mut self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + self.recheck(&is_cancelled)?; + for index in 0..self.entries.len() { + if let Err(primary) = swap_one(&mut self.entries[index]) { + if let Err(restore) = self.restore() { + return Err(MutationError::RestoreFailed { + message: format!("{primary}; {restore}"), + }); + } + return Err(primary); + } + } + Ok(()) + } + + pub fn verify(&self, is_cancelled: F) -> Result<(), MutationError> + where + F: Fn() -> bool, + { + for entry in &self.entries { + check_cancelled(&is_cancelled)?; + let valid = match &entry.intent.action { + EntryAction::Keep => { + fingerprint_path(&entry.intent.destination)? + == entry.intent.expected_fingerprint + && entry + .intent + .expected_content_hash + .as_ref() + .is_none_or(|expected| { + content_hash_path(&entry.intent.destination).as_ref() + == Ok(expected) + }) + } + EntryAction::Materialize { payload_root } => { + verify_materialized(payload_root, &entry.intent.destination).is_ok() + } + EntryAction::Symlink { target } => { + fs::symlink_metadata(&entry.intent.destination) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && fs::read_link(&entry.intent.destination) + .is_ok_and(|actual| &actual == target) + } + EntryAction::Remove => { + fingerprint_path(&entry.intent.destination).as_deref() == Ok("entry-v1-missing") + } + }; + if !valid { + return Err(MutationError::VerificationFailed); + } + } + Ok(()) + } + + pub fn restore(&mut self) -> Result<(), MutationError> { + for entry in self.entries.iter_mut().rev() { + if entry.installed { + remove_no_follow(&entry.intent.destination)?; + entry.installed = false; + } + if entry.backup_created { + fs::rename(&entry.backup, &entry.intent.destination)?; + entry.backup_created = false; + } + } + Ok(()) + } + + pub fn recovery_entries(&self) -> Vec { + self.entries + .iter() + .filter(|entry| !matches!(entry.intent.action, EntryAction::Keep)) + .map(|entry| RecoveryEntry { + destination: entry.intent.destination.clone(), + backup: entry.backup.clone(), + expected_present: !matches!(entry.intent.action, EntryAction::Remove), + original_fingerprint: entry.intent.expected_fingerprint.clone(), + }) + .collect() + } + + pub fn cleanup_stages(&mut self) -> Vec { + let mut warnings = Vec::new(); + for entry in &mut self.entries { + if let Some(path) = entry.stage.clone() { + if let Err(error) = remove_no_follow(&path) { + warnings.push(format!("{}: {error}", path.display())); + } else { + entry.stage = None; + } + } + } + warnings + } + + pub fn cleanup(mut self) -> Result, MutationError> { + let mut warnings = self.cleanup_stages(); + for entry in self.entries { + if entry.backup_created { + if let Err(error) = remove_no_follow(&entry.backup) { + warnings.push(format!("{}: {error}", entry.backup.display())); + } + } + } + Ok(warnings) + } +} + +#[cfg(not(target_os = "linux"))] +fn parent_identity_platform(_path: &Path) -> Result { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn parent_identity_platform(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let metadata = fs::metadata(path)?; + if !metadata.is_dir() { + return Err(MutationError::InvalidRequest); + } + Ok(ParentIdentity { + device: metadata.dev(), + inode: metadata.ino(), + }) +} + +#[cfg(not(target_os = "linux"))] +fn stage_platform( + _operation_id: &str, + _intents: Vec, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn stage_platform( + operation_id: &str, + intents: Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result { + if intents.is_empty() + || operation_id.is_empty() + || !operation_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(MutationError::InvalidRequest); + } + validate_intents_platform(&intents, is_cancelled)?; + let mut entries = Vec::with_capacity(intents.len()); + for (index, intent) in intents.into_iter().enumerate() { + let mut current_stage = None; + let staged = (|| { + check_cancelled(is_cancelled)?; + let parent = intent + .destination + .parent() + .ok_or(MutationError::InvalidRequest)?; + let parent_identity = match &intent.action { + EntryAction::Keep | EntryAction::Remove => None, + EntryAction::Materialize { .. } | EntryAction::Symlink { .. } => { + fs::create_dir_all(parent)?; + Some(parent_identity(parent)?) + } + }; + let stage = sibling(&intent.destination, "stage", operation_id, index)?; + let backup = sibling(&intent.destination, "backup", operation_id, index)?; + if fs::symlink_metadata(&stage).is_ok() || fs::symlink_metadata(&backup).is_ok() { + return Err(MutationError::StaleTarget); + } + current_stage = Some(stage.clone()); + let staged_path = match &intent.action { + EntryAction::Keep | EntryAction::Remove => None, + EntryAction::Materialize { payload_root } => { + materialize(payload_root, &stage)?; + Some(stage) + } + EntryAction::Symlink { target } => { + use std::os::unix::fs::symlink; + symlink(target, &stage)?; + Some(stage) + } + }; + let staged = StagedEntry { + intent, + parent_identity, + stage: staged_path, + backup, + backup_created: false, + installed: false, + }; + verify_stage(&staged)?; + Ok(staged) + })(); + match staged { + Ok(staged) => entries.push(staged), + Err(error) => { + if let Some(stage) = current_stage { + let _ = remove_no_follow(&stage); + } + cleanup_partial(&mut entries); + return Err(error); + } + } + } + Ok(StagedMutation { entries }) +} + +#[cfg(not(target_os = "linux"))] +fn validate_intents_platform( + _intents: &[EntryIntent], + _is_cancelled: &impl Fn() -> bool, +) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn validate_intents_platform( + intents: &[EntryIntent], + is_cancelled: &impl Fn() -> bool, +) -> Result<(), MutationError> { + if intents.is_empty() { + return Err(MutationError::InvalidRequest); + } + let mut destinations = BTreeSet::new(); + for intent in intents { + check_cancelled(is_cancelled)?; + if !intent.destination.is_absolute() || !destinations.insert(&intent.destination) { + return Err(MutationError::InvalidRequest); + } + validate_intent_platform(intent)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_intent_platform(intent: &EntryIntent) -> Result<(), MutationError> { + let projection = project_targets(&ProjectionRequest { + destinations: vec![intent.destination.clone()], + }) + .map_err(|_| MutationError::StaleTarget)? + .targets + .into_iter() + .next() + .ok_or(MutationError::StaleTarget)?; + if projection.anchor_device != intent.expected_parent.device + || projection.anchor_inode != intent.expected_parent.inode + || projection.physical_destination != intent.destination + || fingerprint_path(&intent.destination)? != intent.expected_fingerprint + { + return Err(MutationError::StaleTarget); + } + if let Some(expected) = &intent.expected_content_hash { + if content_hash_path(&intent.destination).as_ref() != Ok(expected) { + return Err(MutationError::StaleTarget); + } + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn validate_intent_platform(_intent: &EntryIntent) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +fn recheck_entry(entry: &StagedEntry) -> Result<(), MutationError> { + if entry.parent_identity.is_none() { + validate_intent_platform(&entry.intent)?; + return verify_stage(entry); + } + let parent = entry + .intent + .destination + .parent() + .ok_or(MutationError::InvalidRequest)?; + if Some(parent_identity(parent)?) != entry.parent_identity + || fingerprint_path(&entry.intent.destination)? != entry.intent.expected_fingerprint + || fs::symlink_metadata(&entry.backup).is_ok() + { + return Err(MutationError::StaleTarget); + } + if let Some(expected) = &entry.intent.expected_content_hash { + if content_hash_path(&entry.intent.destination).as_ref() != Ok(expected) { + return Err(MutationError::StaleTarget); + } + } + verify_stage(entry) +} + +fn verify_stage(entry: &StagedEntry) -> Result<(), MutationError> { + match (&entry.intent.action, &entry.stage) { + (EntryAction::Materialize { payload_root }, Some(stage)) => { + verify_materialized(payload_root, stage) + } + (EntryAction::Symlink { target }, Some(stage)) + if fs::symlink_metadata(stage).is_ok_and(|value| value.file_type().is_symlink()) + && fs::read_link(stage).is_ok_and(|actual| &actual == target) => + { + Ok(()) + } + (EntryAction::Keep | EntryAction::Remove, None) => Ok(()), + _ => Err(MutationError::VerificationFailed), + } +} + +fn swap_one(entry: &mut StagedEntry) -> Result<(), MutationError> { + if matches!(entry.intent.action, EntryAction::Keep) { + return Ok(()); + } + if fingerprint_path(&entry.intent.destination)?.as_str() != "entry-v1-missing" { + fs::rename(&entry.intent.destination, &entry.backup)?; + entry.backup_created = true; + } + if let Some(stage) = entry.stage.take() { + fs::rename(stage, &entry.intent.destination)?; + entry.installed = true; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn sibling( + destination: &Path, + kind: &str, + operation_id: &str, + index: usize, +) -> Result { + let parent = destination.parent().ok_or(MutationError::InvalidRequest)?; + Ok(parent.join(format!(".skill-deck-{kind}-{operation_id}-{index:06}"))) +} + +#[cfg(target_os = "linux")] +fn materialize(payload_root: &Path, destination: &Path) -> Result<(), MutationError> { + let manifest = verify_payload(payload_root).map_err(|_| MutationError::InvalidPayload)?; + fs::create_dir(destination)?; + for entry in &manifest.entries { + let path = destination.join(&entry.relative_path); + match entry.kind { + PayloadEntryKind::Directory => fs::create_dir_all(path)?, + PayloadEntryKind::File => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let blob_id = entry + .blob_id + .as_deref() + .ok_or(MutationError::InvalidPayload)?; + let mut input = read_blob(payload_root, blob_id) + .map_err(|_| MutationError::InvalidPayload)? + .ok_or(MutationError::InvalidPayload)?; + let mut output = fs::File::create(&path)?; + std::io::copy(&mut input, &mut output)?; + set_executable(&path, entry.executable)?; + } + } + } + Ok(()) +} + +fn verify_materialized(payload_root: &Path, destination: &Path) -> Result<(), MutationError> { + let manifest = verify_payload(payload_root).map_err(|_| MutationError::InvalidPayload)?; + verify_manifest_tree(&manifest, payload_root, destination) +} + +fn verify_manifest_tree( + manifest: &PayloadManifest, + payload_root: &Path, + destination: &Path, +) -> Result<(), MutationError> { + let expected = manifest + .entries + .iter() + .map(|entry| (PathBuf::from(&entry.relative_path), entry)) + .collect::>(); + let mut actual = BTreeSet::new(); + let mut pending = vec![destination.to_path_buf()]; + while let Some(directory) = pending.pop() { + for child in fs::read_dir(directory)? { + let path = child?.path(); + let relative = path + .strip_prefix(destination) + .map_err(|_| MutationError::VerificationFailed)? + .to_path_buf(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !actual.insert(relative.clone()) { + return Err(MutationError::VerificationFailed); + } + let entry = expected + .get(&relative) + .ok_or(MutationError::VerificationFailed)?; + match entry.kind { + PayloadEntryKind::Directory if metadata.is_dir() => pending.push(path), + PayloadEntryKind::File if metadata.is_file() => { + let blob_id = entry + .blob_id + .as_deref() + .ok_or(MutationError::InvalidPayload)?; + let blob = payload_root.join("blobs").join(blob_id); + if metadata.len() != entry.size + || digest_file(&path)? != blob_id + || digest_file(&blob)? != blob_id + || is_executable(&path)? != entry.executable + { + return Err(MutationError::VerificationFailed); + } + } + _ => return Err(MutationError::VerificationFailed), + } + } + } + if actual.len() == expected.len() { + Ok(()) + } else { + Err(MutationError::VerificationFailed) + } +} + +pub fn content_hash_path(root: &Path) -> Result { + let response = build_manifest(&ManifestRequest { + root: root.to_path_buf(), + }) + .map_err(|_| MutationError::StaleTarget)?; + aggregate_manifest_hash(&response) +} + +fn aggregate_manifest_hash(manifest: &ManifestResponse) -> Result { + let mut records = manifest + .records + .iter() + .map(|record| { + let relative = record + .relative_path + .as_os_str() + .to_str() + .ok_or(MutationError::StaleTarget)? + .nfc() + .collect::(); + let target = record + .symlink_target + .as_ref() + .map(|target| { + target + .as_os_str() + .to_str() + .ok_or(MutationError::StaleTarget) + .map(|target| target.nfc().collect::()) + }) + .transpose()?; + Ok((relative, target, record)) + }) + .collect::, MutationError>>()?; + records.sort_by(|left, right| left.0.cmp(&right.0)); + if records.windows(2).any(|pair| pair[0].0 == pair[1].0) { + return Err(MutationError::StaleTarget); + } + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-content-manifest"); + hasher.update([1]); + for (relative, target, record) in records { + let tag = match record.kind { + ManifestKind::Directory => b'd', + ManifestKind::File => b'f', + ManifestKind::Symlink => b'l', + }; + hasher.update([tag, u8::from(record.executable)]); + hash_field(&mut hasher, relative.as_bytes()); + hash_field( + &mut hasher, + record.digest.as_deref().unwrap_or("").as_bytes(), + ); + hash_field(&mut hasher, target.as_deref().unwrap_or("").as_bytes()); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn digest_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + return Ok(format!("{:x}", hasher.finalize())); + } + hasher.update(&buffer[..read]); + } +} + +#[cfg(target_os = "linux")] +fn cleanup_partial(entries: &mut Vec) { + while let Some(entry) = entries.pop() { + if let Some(stage) = entry.stage { + let _ = remove_no_follow(&stage); + } + } +} + +fn remove_no_follow(path: &Path) -> Result<(), MutationError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(path)? + } + Ok(_) => fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +fn check_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), MutationError> { + if is_cancelled() { + Err(MutationError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn set_executable(path: &Path, executable: bool) -> Result<(), MutationError> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path)?.permissions(); + let mut mode = permissions.mode(); + if executable { + mode |= 0o111; + } else { + mode &= !0o111; + } + permissions.set_mode(mode); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + Ok(fs::metadata(path)?.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(_path: &Path) -> Result { + Err(MutationError::UnsupportedPlatform) +} diff --git a/src-tauri/crates/environment-engine/src/lock.rs b/src-tauri/crates/environment-engine/src/lock.rs new file mode 100644 index 00000000..4ec2b121 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/lock.rs @@ -0,0 +1,356 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LockSchema { + Global, + Project, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntryMutation { + Replace { + key: String, + replacement: Value, + }, + Remove { + key: String, + }, + MoveAndReplace { + from: String, + to: String, + replacement: Value, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockMutation { + pub schema: LockSchema, + pub entry: EntryMutation, + pub root_replacements: BTreeMap, + pub expected_entries: BTreeMap>, + pub expected_roots: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockReceipt { + pub entries: BTreeMap>, + pub roots: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedLock { + pub bytes: Vec, + pub receipt: LockReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockError { + InvalidDocument { message: String }, + UnsupportedSchema { version: u64, supported: u64 }, + MissingExpectedEntry { key: String }, + MissingExpectedRoot { field: String }, + EntryConflict { key: String }, + RootConflict { field: String }, +} + +impl fmt::Display for LockError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LockError {} + +pub fn apply( + current: Option<&[u8]>, + legacy: Option<&[u8]>, + mutation: &LockMutation, +) -> Result { + let mut document = load_document(current, legacy, mutation.schema)?; + for key in affected_keys(&mutation.entry) { + let expected = + mutation + .expected_entries + .get(key) + .ok_or_else(|| LockError::MissingExpectedEntry { + key: key.to_string(), + })?; + document.validate_entry(key, expected)?; + } + match &mutation.entry { + EntryMutation::Replace { key, replacement } => { + document.replace_entry(mutation.schema, key, replacement.clone()) + } + EntryMutation::Remove { key } => document.remove_entry(key), + EntryMutation::MoveAndReplace { + from, + to, + replacement, + } => document.move_and_replace(mutation.schema, from, to, replacement.clone()), + } + for (field, replacement) in &mutation.root_replacements { + let expected = + mutation + .expected_roots + .get(field) + .ok_or_else(|| LockError::MissingExpectedRoot { + field: field.clone(), + })?; + document.replace_root(field, expected, replacement.clone())?; + } + let receipt = LockReceipt { + entries: mutation + .expected_entries + .keys() + .map(|key| (key.clone(), document.entry(key).cloned())) + .collect(), + roots: mutation + .expected_roots + .keys() + .map(|field| (field.clone(), document.root.get(field).cloned())) + .collect(), + }; + let mut bytes = serde_json::to_vec_pretty(&document.root).map_err(json_error)?; + bytes.push(b'\n'); + Ok(AppliedLock { bytes, receipt }) +} + +fn affected_keys(entry: &EntryMutation) -> Vec<&str> { + match entry { + EntryMutation::Replace { key, .. } | EntryMutation::Remove { key } => vec![key], + EntryMutation::MoveAndReplace { from, to, .. } => vec![from, to], + } +} + +fn load_document( + current: Option<&[u8]>, + legacy: Option<&[u8]>, + schema: LockSchema, +) -> Result { + if let Some(bytes) = current { + ensure_supported_schema(bytes, schema)?; + return Document::parse(bytes); + } + let Some(bytes) = legacy else { + return Ok(Document::empty(schema)); + }; + let document = Document::parse(bytes)?; + match schema { + LockSchema::Global => Ok(document), + LockSchema::Project => convert_legacy_project(document), + } +} + +fn ensure_supported_schema(bytes: &[u8], schema: LockSchema) -> Result<(), LockError> { + let value: Value = serde_json::from_slice(bytes).map_err(json_error)?; + let version = value + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| LockError::InvalidDocument { + message: "lock version is missing".to_string(), + })?; + let supported = schema_version(schema); + if version > supported { + Err(LockError::UnsupportedSchema { version, supported }) + } else { + Ok(()) + } +} + +struct Document { + root: Value, +} + +impl Document { + fn parse(bytes: &[u8]) -> Result { + let root: Value = serde_json::from_slice(bytes).map_err(json_error)?; + let object = root.as_object().ok_or_else(|| LockError::InvalidDocument { + message: "lock root must be a JSON object".to_string(), + })?; + if !object.get("skills").is_some_and(Value::is_object) { + return Err(LockError::InvalidDocument { + message: "lock skills must be a JSON object".to_string(), + }); + } + Ok(Self { root }) + } + + fn empty(schema: LockSchema) -> Self { + Self { + root: serde_json::json!({ "version": schema_version(schema), "skills": {} }), + } + } + + fn skills(&self) -> &Map { + self.root["skills"] + .as_object() + .expect("validated lock skills") + } + + fn skills_mut(&mut self) -> &mut Map { + self.root["skills"] + .as_object_mut() + .expect("validated lock skills") + } + + fn entry(&self, key: &str) -> Option<&Value> { + self.skills().get(key) + } + + fn validate_entry(&self, key: &str, expected: &Option) -> Result<(), LockError> { + if self.entry(key) == expected.as_ref() { + Ok(()) + } else { + Err(LockError::EntryConflict { + key: key.to_string(), + }) + } + } + + fn replace_entry(&mut self, schema: LockSchema, key: &str, replacement: Value) { + let replacement = merge_entry_fields(schema, self.entry(key), replacement); + self.skills_mut().insert(key.to_string(), replacement); + } + + fn remove_entry(&mut self, key: &str) { + self.skills_mut().remove(key); + } + + fn move_and_replace(&mut self, schema: LockSchema, from: &str, to: &str, replacement: Value) { + let replacement = merge_entry_fields(schema, self.entry(from), replacement); + self.skills_mut().remove(from); + self.skills_mut().insert(to.to_string(), replacement); + } + + fn replace_root( + &mut self, + field: &str, + expected: &Option, + replacement: Value, + ) -> Result<(), LockError> { + if self.root.get(field) != expected.as_ref() { + return Err(LockError::RootConflict { + field: field.to_string(), + }); + } + self.root + .as_object_mut() + .expect("validated lock root") + .insert(field.to_string(), replacement); + Ok(()) + } +} + +fn schema_version(schema: LockSchema) -> u64 { + match schema { + LockSchema::Global => 3, + LockSchema::Project => 1, + } +} + +fn merge_entry_fields(schema: LockSchema, current: Option<&Value>, replacement: Value) -> Value { + let known_fields: &[&str] = match schema { + LockSchema::Global => &[ + "source", + "sourceType", + "sourceUrl", + "ref", + "skillPath", + "skillFolderHash", + "installedAt", + "updatedAt", + "pluginName", + "sourceBaseUrl", + "wellKnownDigest", + ], + LockSchema::Project => &[ + "source", + "ref", + "sourceType", + "sourceUrl", + "computedHash", + "remoteHash", + "skillPath", + "subagents", + "pluginName", + "wellKnownDigest", + ], + }; + match (current.and_then(Value::as_object), replacement) { + (current, Value::Object(replacement)) => { + let mut merged = current.cloned().unwrap_or_default(); + for field in known_fields { + merged.remove(*field); + } + merged.extend(replacement); + Value::Object(merged) + } + (_, replacement) => replacement, + } +} + +fn convert_legacy_project(document: Document) -> Result { + let mut root = + document + .root + .as_object() + .cloned() + .ok_or_else(|| LockError::InvalidDocument { + message: "lock root must be a JSON object".to_string(), + })?; + root.insert("version".to_string(), Value::from(1)); + for field in ["dismissed", "lastSelectedAgents", "defaultTargetAgents"] { + root.remove(field); + } + let skills = root + .get_mut("skills") + .and_then(Value::as_object_mut) + .expect("validated lock skills"); + for entry in skills.values_mut() { + let Some(current) = entry.as_object().cloned() else { + continue; + }; + let mut replacement = Map::new(); + for field in [ + "source", + "ref", + "sourceType", + "sourceUrl", + "skillPath", + "pluginName", + ] { + if let Some(value) = current.get(field) { + replacement.insert(field.to_string(), value.clone()); + } + } + replacement.insert("computedHash".to_string(), Value::String(String::new())); + if let Some(remote_hash) = current + .get("skillFolderHash") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + replacement.insert( + "remoteHash".to_string(), + Value::String(remote_hash.to_string()), + ); + } + *entry = merge_entry_fields( + LockSchema::Global, + Some(&Value::Object(current)), + Value::Object(replacement), + ); + } + Ok(Document { + root: Value::Object(root), + }) +} + +fn json_error(error: serde_json::Error) -> LockError { + LockError::InvalidDocument { + message: error.to_string(), + } +} diff --git a/src-tauri/crates/environment-engine/src/manifest.rs b/src-tauri/crates/environment-engine/src/manifest.rs new file mode 100644 index 00000000..b9e9558a --- /dev/null +++ b/src-tauri/crates/environment-engine/src/manifest.rs @@ -0,0 +1,163 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[cfg(target_os = "linux")] +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestRequest { + pub root: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestKind { + Directory, + File, + Symlink, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestRecord { + pub relative_path: PathBuf, + pub kind: ManifestKind, + pub digest: Option, + pub executable: bool, + pub symlink_target: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestResponse { + pub records: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + UnsupportedEntry, + Cancelled, +} + +impl fmt::Display for ManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ManifestError {} + +pub fn build_manifest(request: &ManifestRequest) -> Result { + build_manifest_with_cancel(request, || false) +} + +pub fn build_manifest_with_cancel( + request: &ManifestRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if !request.root.is_absolute() { + return Err(ManifestError::InvalidRequest); + } + build_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn build_platform( + _request: &ManifestRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(ManifestError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn build_platform( + request: &ManifestRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let metadata = fs::symlink_metadata(&request.root).map_err(|_| ManifestError::Unavailable)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(ManifestError::InvalidRequest); + } + let mut records = Vec::new(); + visit(&request.root, Path::new(""), is_cancelled, &mut records)?; + Ok(ManifestResponse { records }) +} + +#[cfg(target_os = "linux")] +fn visit( + directory: &Path, + relative_parent: &Path, + is_cancelled: &impl Fn() -> bool, + records: &mut Vec, +) -> Result<(), ManifestError> { + let mut entries = fs::read_dir(directory) + .map_err(|_| ManifestError::Unavailable)? + .collect::, _>>() + .map_err(|_| ManifestError::Unavailable)?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + if is_cancelled() { + return Err(ManifestError::Cancelled); + } + let relative_path = relative_parent.join(entry.file_name()); + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| ManifestError::Unavailable)?; + if metadata.file_type().is_symlink() { + records.push(ManifestRecord { + relative_path, + kind: ManifestKind::Symlink, + digest: None, + executable: false, + symlink_target: Some(fs::read_link(path).map_err(|_| ManifestError::Unavailable)?), + }); + } else if metadata.is_dir() { + records.push(ManifestRecord { + relative_path: relative_path.clone(), + kind: ManifestKind::Directory, + digest: None, + executable: false, + symlink_target: None, + }); + visit(&path, &relative_path, is_cancelled, records)?; + } else if metadata.is_file() { + use std::os::unix::fs::PermissionsExt; + records.push(ManifestRecord { + relative_path, + kind: ManifestKind::File, + digest: Some(digest_file(&path)?), + executable: metadata.permissions().mode() & 0o111 != 0, + symlink_target: None, + }); + } else { + return Err(ManifestError::UnsupportedEntry); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn digest_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|_| ManifestError::Unavailable)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| ManifestError::Unavailable)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/src-tauri/crates/environment-engine/src/path.rs b/src-tauri/crates/environment-engine/src/path.rs new file mode 100644 index 00000000..eb485bec --- /dev/null +++ b/src-tauri/crates/environment-engine/src/path.rs @@ -0,0 +1,185 @@ +use std::fmt; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathRequest { + pub queries: Vec, + pub aggregate_content_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathQuery { + pub path: PathBuf, + pub content_limit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + Missing, + Directory, + SymlinkDirectory, + SymlinkOther, + Other, + BrokenLink, + Inaccessible, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContentState { + NotRequested, + Empty, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathFact { + pub path: PathBuf, + pub kind: PathKind, + pub content: ContentState, + pub content_truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathResponse { + pub facts: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for PathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => { + formatter.write_str("Linux path inspection is unavailable") + } + Self::InvalidRequest => formatter.write_str("invalid bounded path inspection request"), + Self::Cancelled => formatter.write_str("path inspection was cancelled"), + } + } +} + +impl std::error::Error for PathError {} + +pub fn inspect_paths(request: &PathRequest) -> Result { + inspect_paths_with_cancel(request, || false) +} + +pub fn inspect_paths_with_cancel( + request: &PathRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.aggregate_content_limit == 0 + || request.queries.iter().any(|query| { + !query.path.is_absolute() || query.content_limit.is_some_and(|limit| limit == 0) + }) + { + return Err(PathError::InvalidRequest); + } + inspect_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn inspect_platform( + _request: &PathRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(PathError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn inspect_platform( + request: &PathRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut total_content_bytes = 0usize; + let mut facts = Vec::with_capacity(request.queries.len()); + for query in &request.queries { + if is_cancelled() { + return Err(PathError::Cancelled); + } + facts.push(inspect_one( + query, + request.aggregate_content_limit as usize, + &mut total_content_bytes, + )); + } + Ok(PathResponse { + facts, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn inspect_one(query: &PathQuery, aggregate_limit: usize, total: &mut usize) -> PathFact { + let kind = match fs::symlink_metadata(&query.path) { + Ok(metadata) if metadata.file_type().is_symlink() => match fs::metadata(&query.path) { + Ok(target) if target.is_dir() => PathKind::SymlinkDirectory, + Ok(_) => PathKind::SymlinkOther, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathKind::BrokenLink, + Err(_) => PathKind::Inaccessible, + }, + Ok(metadata) if metadata.is_dir() => PathKind::Directory, + Ok(_) => PathKind::Other, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathKind::Missing, + Err(_) => PathKind::Inaccessible, + }; + let Some(per_file_limit) = query.content_limit else { + return PathFact { + path: query.path.clone(), + kind, + content: ContentState::NotRequested, + content_truncated: false, + }; + }; + let metadata = match fs::metadata(&query.path) { + Ok(metadata) if metadata.is_file() => metadata, + _ => { + return PathFact { + path: query.path.clone(), + kind, + content: ContentState::NotRequested, + content_truncated: false, + }; + } + }; + let remaining = aggregate_limit.saturating_sub(*total); + let limit = remaining.min(per_file_limit as usize); + let mut bytes = Vec::new(); + let content = match fs::File::open(&query.path) { + Ok(file) => { + if file.take(limit as u64).read_to_end(&mut bytes).is_ok() { + *total += bytes.len(); + if metadata.len() == 0 { + ContentState::Empty + } else { + ContentState::Bytes(bytes) + } + } else { + ContentState::Unreadable + } + } + _ => ContentState::Unreadable, + }; + PathFact { + path: query.path.clone(), + kind, + content, + content_truncated: metadata.len() > limit as u64, + } +} diff --git a/src-tauri/crates/environment-engine/src/payload.rs b/src-tauri/crates/environment-engine/src/payload.rs new file mode 100644 index 00000000..81375b11 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/payload.rs @@ -0,0 +1,647 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io::{BufReader, Read, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const EXCLUDED_PAYLOAD_FILES: &[&str] = &["metadata.json"]; +const EXCLUDED_PAYLOAD_DIRS: &[&str] = &[".git", "__pycache__", "__pypackages__"]; +const EXCLUDED_CLI_DIRS: &[&str] = &[".git", "node_modules"]; +const MANIFEST_FILE: &str = "manifest.json"; +const BLOB_LIST_FILE: &str = "blob-list"; +const BLOBS_DIRECTORY: &str = "blobs"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PayloadEntryKind { + File, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadEntry { + pub relative_path: String, + pub kind: PayloadEntryKind, + pub blob_id: Option, + pub content_hash: Option, + pub size: u64, + pub executable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PayloadManifest { + pub entries: Vec, + pub payload_root_hash: String, + pub payload_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltPayload { + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: String, +} + +#[derive(Debug)] +pub enum PayloadError { + Io(std::io::Error), + Json(serde_json::Error), + InvalidSource, + DestinationExists, + UnsafeSourceLink { path: PathBuf }, + InvalidPayload, + UnsupportedPlatform, + Cancelled, +} + +impl fmt::Display for PayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for PayloadError {} + +impl From for PayloadError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PayloadError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +pub fn build_payload( + source_root: &Path, + payload_root: &Path, +) -> Result { + build_payload_with_cancel(source_root, payload_root, || false) +} + +pub fn build_payload_with_cancel( + source_root: &Path, + payload_root: &Path, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + let physical_root = fs::canonicalize(source_root).map_err(|_| PayloadError::InvalidSource)?; + if !physical_root.is_dir() { + return Err(PayloadError::InvalidSource); + } + if fs::symlink_metadata(payload_root).is_ok() { + return Err(PayloadError::DestinationExists); + } + fs::create_dir(payload_root)?; + let result = build_payload_in_root(&physical_root, payload_root, &is_cancelled); + if result.is_err() { + let _ = fs::remove_dir_all(payload_root); + } + result +} + +pub fn verify_payload(payload_root: &Path) -> Result { + ensure_payload_root(payload_root)?; + let manifest_path = payload_root.join(MANIFEST_FILE); + ensure_regular_file(&manifest_path)?; + let manifest: PayloadManifest = serde_json::from_reader(fs::File::open(manifest_path)?)?; + verify_manifest(&manifest)?; + + let blob_ids = manifest_blob_ids(&manifest)?; + let blob_list_path = payload_root.join(BLOB_LIST_FILE); + ensure_regular_file(&blob_list_path)?; + let blob_list = fs::read_to_string(blob_list_path)?; + let listed = blob_list + .lines() + .map(str::to_owned) + .collect::>(); + if listed != blob_ids || blob_list.lines().count() != blob_ids.len() { + return Err(PayloadError::InvalidPayload); + } + for blob_id in blob_ids { + let blob = payload_root.join(BLOBS_DIRECTORY).join(&blob_id); + ensure_regular_file(&blob)?; + if file_sha256(&blob)? != blob_id { + return Err(PayloadError::InvalidPayload); + } + } + Ok(manifest) +} + +pub fn read_blob(payload_root: &Path, blob_id: &str) -> Result, PayloadError> { + if !valid_blob_id(blob_id) { + return Err(PayloadError::InvalidPayload); + } + ensure_payload_root(payload_root)?; + let path = payload_root.join(BLOBS_DIRECTORY).join(blob_id); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + if file_sha256(&path)? != blob_id { + return Err(PayloadError::InvalidPayload); + } + Ok(Some(fs::File::open(path)?)) + } + Ok(_) => Err(PayloadError::InvalidPayload), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +pub fn source_metadata_fingerprint(source_root: &Path) -> Result { + source_metadata_fingerprint_platform(source_root) +} + +#[cfg(not(target_os = "linux"))] +fn source_metadata_fingerprint_platform(_source_root: &Path) -> Result { + Err(PayloadError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn source_metadata_fingerprint_platform(source_root: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + let physical_root = fs::canonicalize(source_root).map_err(|_| PayloadError::InvalidSource)?; + if !physical_root.is_dir() { + return Err(PayloadError::InvalidSource); + } + let mut paths = Vec::new(); + collect_fingerprint_paths(&physical_root, &physical_root, Path::new(""), &mut paths)?; + paths.sort_by(|left, right| left.1.cmp(&right.1)); + + let mut hasher = Sha256::new(); + for (path, relative) in paths { + let link_metadata = fs::symlink_metadata(&path)?; + let (kind, target, metadata) = if link_metadata.file_type().is_symlink() { + let _ = safe_internal_link_target(&physical_root, &path)?; + ( + b"link".as_slice(), + fs::read_link(&path)?.into_os_string().as_bytes().to_vec(), + fs::metadata(&path).map_err(|_| unsafe_link(&path))?, + ) + } else if link_metadata.is_dir() { + (b"directory".as_slice(), Vec::new(), link_metadata) + } else if link_metadata.is_file() { + (b"file".as_slice(), Vec::new(), link_metadata) + } else { + (b"other".as_slice(), Vec::new(), link_metadata) + }; + hasher.update(relative.as_os_str().as_bytes()); + hasher.update([0]); + hasher.update(kind); + hasher.update([0]); + hasher.update(metadata.len().to_string().as_bytes()); + hasher.update([0]); + hasher.update(metadata.mtime().to_string().as_bytes()); + hasher.update([0]); + hasher.update(format!("{:o}", metadata.mode() & 0o7777).as_bytes()); + hasher.update([0]); + hasher.update(target); + hasher.update([0]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(target_os = "linux")] +fn collect_fingerprint_paths( + physical_root: &Path, + current: &Path, + relative_root: &Path, + paths: &mut Vec<(PathBuf, PathBuf)>, +) -> Result<(), PayloadError> { + for entry in sorted_directory_entries(current)? { + let name = entry.file_name(); + let name_text = name.to_string_lossy(); + let path = entry.path(); + let relative = relative_root.join(&name); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() && EXCLUDED_PAYLOAD_DIRS.contains(&name_text.as_ref()) { + continue; + } + if metadata.is_file() && EXCLUDED_PAYLOAD_FILES.contains(&name_text.as_ref()) { + continue; + } + if metadata.file_type().is_symlink() { + let _ = safe_internal_link_target(physical_root, &path)?; + paths.push((path, relative)); + } else if metadata.is_dir() { + paths.push((path.clone(), relative.clone())); + collect_fingerprint_paths(physical_root, &path, &relative, paths)?; + } else { + paths.push((path, relative)); + } + } + Ok(()) +} + +fn build_payload_in_root( + physical_root: &Path, + payload_root: &Path, + is_cancelled: &impl Fn() -> bool, +) -> Result { + ensure_not_cancelled(is_cancelled)?; + let blobs_root = payload_root.join(BLOBS_DIRECTORY); + fs::create_dir(&blobs_root)?; + let mut entries = Vec::new(); + let mut ancestors = vec![physical_root.to_path_buf()]; + collect_payload_entries( + physical_root, + physical_root, + Path::new(""), + &blobs_root, + &mut entries, + &mut ancestors, + is_cancelled, + )?; + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let payload_root_hash = compute_payload_root_hash(&entries); + let manifest = PayloadManifest { + entries, + payload_id: payload_root_hash.clone(), + payload_root_hash, + }; + verify_manifest(&manifest)?; + let blob_ids = manifest_blob_ids(&manifest)?; + write_blob_list(&payload_root.join(BLOB_LIST_FILE), &blob_ids)?; + serde_json::to_writer( + fs::File::create(payload_root.join(MANIFEST_FILE))?, + &manifest, + )?; + let total_bytes = manifest + .entries + .iter() + .filter(|entry| entry.kind == PayloadEntryKind::File) + .map(|entry| (entry.blob_id.as_deref().unwrap(), entry.size)) + .collect::>() + .values() + .sum(); + let computed_hash = compute_cli_hash(&manifest, &blobs_root)?; + Ok(BuiltPayload { + manifest, + total_bytes, + computed_hash, + }) +} + +fn collect_payload_entries( + physical_root: &Path, + current: &Path, + relative_root: &Path, + blobs_root: &Path, + entries: &mut Vec, + ancestors: &mut Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result<(), PayloadError> { + for entry in sorted_directory_entries(current)? { + ensure_not_cancelled(is_cancelled)?; + let name = entry.file_name(); + let name_text = name.to_string_lossy(); + if EXCLUDED_PAYLOAD_DIRS.contains(&name_text.as_ref()) { + continue; + } + let path = entry.path(); + let relative = relative_root.join(&name); + let relative_path = normalized_relative_path(&relative)?; + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + let target = safe_internal_link_target(physical_root, &path)?; + let target_metadata = fs::metadata(&target).map_err(|_| unsafe_link(&path))?; + if target_metadata.is_dir() { + if ancestors.contains(&target) { + return Err(unsafe_link(&path)); + } + entries.push(directory_entry(relative_path)); + ancestors.push(target.clone()); + let result = collect_payload_entries( + physical_root, + &target, + &relative, + blobs_root, + entries, + ancestors, + is_cancelled, + ); + ancestors.pop(); + result?; + } else if target_metadata.is_file() { + add_payload_file( + &target, + relative_path, + &target_metadata, + blobs_root, + entries, + is_cancelled, + )?; + } else { + return Err(unsafe_link(&path)); + } + } else if metadata.is_dir() { + entries.push(directory_entry(relative_path)); + let canonical = fs::canonicalize(&path).map_err(|_| unsafe_link(&path))?; + ancestors.push(canonical); + let result = collect_payload_entries( + physical_root, + &path, + &relative, + blobs_root, + entries, + ancestors, + is_cancelled, + ); + ancestors.pop(); + result?; + } else if metadata.is_file() && !EXCLUDED_PAYLOAD_FILES.contains(&name_text.as_ref()) { + add_payload_file( + &path, + relative_path, + &metadata, + blobs_root, + entries, + is_cancelled, + )?; + } + } + Ok(()) +} + +fn add_payload_file( + source: &Path, + relative_path: String, + metadata: &fs::Metadata, + blobs_root: &Path, + entries: &mut Vec, + is_cancelled: &impl Fn() -> bool, +) -> Result<(), PayloadError> { + let temporary = blobs_root.join(format!(".copy-{}", entries.len())); + let mut input = fs::File::open(source)?; + let mut output = fs::File::create(&temporary)?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + ensure_not_cancelled(is_cancelled)?; + let read = input.read(&mut buffer)?; + if read == 0 { + break; + } + output.write_all(&buffer[..read])?; + } + output.flush()?; + let blob_id = file_sha256(&temporary)?; + let blob = blobs_root.join(&blob_id); + if fs::symlink_metadata(&blob).is_ok() { + fs::remove_file(&temporary)?; + } else { + fs::rename(&temporary, &blob)?; + } + set_private_permissions(&blob)?; + let copied_size = fs::metadata(&blob)?.len(); + entries.push(PayloadEntry { + relative_path, + kind: PayloadEntryKind::File, + blob_id: Some(blob_id.clone()), + content_hash: Some(blob_id), + size: copied_size, + executable: is_executable(metadata), + }); + Ok(()) +} + +fn verify_manifest(manifest: &PayloadManifest) -> Result<(), PayloadError> { + if manifest.payload_id != manifest.payload_root_hash + || compute_payload_root_hash(&manifest.entries) != manifest.payload_root_hash + { + return Err(PayloadError::InvalidPayload); + } + let mut previous_path = None; + for entry in &manifest.entries { + if entry.relative_path.is_empty() + || entry.relative_path.starts_with('/') + || entry.relative_path.contains('\\') + || entry + .relative_path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + || previous_path.is_some_and(|previous| previous >= entry.relative_path.as_str()) + { + return Err(PayloadError::InvalidPayload); + } + previous_path = Some(entry.relative_path.as_str()); + match entry.kind { + PayloadEntryKind::Directory + if entry.blob_id.is_some() + || entry.content_hash.is_some() + || entry.size != 0 + || entry.executable => + { + return Err(PayloadError::InvalidPayload); + } + PayloadEntryKind::File + if entry.blob_id.is_none() + || entry.blob_id.as_deref() != entry.content_hash.as_deref() + || !entry.blob_id.as_deref().is_some_and(valid_blob_id) => + { + return Err(PayloadError::InvalidPayload); + } + _ => {} + } + } + Ok(()) +} + +fn manifest_blob_ids(manifest: &PayloadManifest) -> Result, PayloadError> { + verify_manifest(manifest)?; + Ok(manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.clone()) + .collect()) +} + +fn compute_payload_root_hash(entries: &[PayloadEntry]) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-payload-v1\0"); + for entry in entries { + hasher.update(entry.relative_path.as_bytes()); + hasher.update([0]); + hasher.update([match entry.kind { + PayloadEntryKind::File => 1, + PayloadEntryKind::Directory => 2, + }]); + hasher.update(entry.size.to_le_bytes()); + hasher.update([u8::from(entry.executable)]); + if let Some(content_hash) = &entry.content_hash { + hasher.update(content_hash.as_bytes()); + } + hasher.update([0]); + } + format!("{:x}", hasher.finalize()) +} + +fn compute_cli_hash(manifest: &PayloadManifest, blobs_root: &Path) -> Result { + let mut files = manifest + .entries + .iter() + .filter(|entry| entry.kind == PayloadEntryKind::File) + .filter(|entry| { + !entry + .relative_path + .split('/') + .any(|component| EXCLUDED_CLI_DIRS.contains(&component)) + }) + .collect::>(); + files.sort_by(|left, right| { + left.relative_path + .to_ascii_lowercase() + .cmp(&right.relative_path.to_ascii_lowercase()) + .then_with(|| left.relative_path.cmp(&right.relative_path)) + }); + let mut hasher = Sha256::new(); + for entry in files { + hasher.update(entry.relative_path.as_bytes()); + let blob_id = entry + .blob_id + .as_deref() + .ok_or(PayloadError::InvalidPayload)?; + hash_file_into(&blobs_root.join(blob_id), &mut hasher)?; + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn file_sha256(path: &Path) -> Result { + let mut hasher = Sha256::new(); + hash_file_into(path, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} + +fn hash_file_into(path: &Path, hasher: &mut Sha256) -> Result<(), PayloadError> { + let mut reader = BufReader::new(fs::File::open(path)?); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(()); + } + hasher.update(&buffer[..read]); + } +} + +fn write_blob_list(path: &Path, blob_ids: &BTreeSet) -> Result<(), PayloadError> { + let mut file = fs::File::create(path)?; + for blob_id in blob_ids { + writeln!(file, "{blob_id}")?; + } + Ok(()) +} + +fn ensure_payload_root(path: &Path) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PayloadError::InvalidPayload)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(PayloadError::InvalidPayload); + } + let blobs = path.join(BLOBS_DIRECTORY); + let metadata = fs::symlink_metadata(blobs).map_err(|_| PayloadError::InvalidPayload)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(PayloadError::InvalidPayload); + } + Ok(()) +} + +fn ensure_regular_file(path: &Path) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(path).map_err(|_| PayloadError::InvalidPayload)?; + if metadata.is_file() && !metadata.file_type().is_symlink() { + Ok(()) + } else { + Err(PayloadError::InvalidPayload) + } +} + +fn safe_internal_link_target(physical_root: &Path, link: &Path) -> Result { + let target = fs::canonicalize(link).map_err(|_| unsafe_link(link))?; + if target.starts_with(physical_root) { + Ok(target) + } else { + Err(unsafe_link(link)) + } +} + +fn unsafe_link(path: &Path) -> PayloadError { + PayloadError::UnsafeSourceLink { + path: path.to_path_buf(), + } +} + +fn directory_entry(relative_path: String) -> PayloadEntry { + PayloadEntry { + relative_path, + kind: PayloadEntryKind::Directory, + blob_id: None, + content_hash: None, + size: 0, + executable: false, + } +} + +fn sorted_directory_entries(path: &Path) -> Result, PayloadError> { + let mut entries = fs::read_dir(path)?.collect::, _>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + Ok(entries) +} + +fn normalized_relative_path(path: &Path) -> Result { + path.components() + .map(|component| { + component + .as_os_str() + .to_str() + .map(str::to_string) + .ok_or(PayloadError::InvalidSource) + }) + .collect::, _>>() + .map(|components| components.join("/")) +} + +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), PayloadError> { + if is_cancelled() { + Err(PayloadError::Cancelled) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) -> Result<(), PayloadError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) -> Result<(), PayloadError> { + Ok(()) +} diff --git a/src-tauri/crates/environment-engine/src/projection.rs b/src-tauri/crates/environment-engine/src/projection.rs new file mode 100644 index 00000000..e5714859 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/projection.rs @@ -0,0 +1,134 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Component; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionRequest { + pub destinations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectedTarget { + pub anchor_device: u64, + pub anchor_inode: u64, + pub physical_anchor: PathBuf, + pub physical_destination: PathBuf, + pub relative_components: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionResponse { + pub targets: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionError { + UnsupportedPlatform, + InvalidRequest, + Unavailable, + Cancelled, +} + +impl fmt::Display for ProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ProjectionError {} + +pub fn project_targets(request: &ProjectionRequest) -> Result { + project_targets_with_cancel(request, || false) +} + +pub fn project_targets_with_cancel( + request: &ProjectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.destinations.is_empty() + || request.destinations.iter().any(|path| !path.is_absolute()) + { + return Err(ProjectionError::InvalidRequest); + } + project_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn project_platform( + _request: &ProjectionRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(ProjectionError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn project_platform( + request: &ProjectionRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + use std::os::unix::fs::MetadataExt; + + let mut targets = Vec::with_capacity(request.destinations.len()); + for destination in &request.destinations { + if is_cancelled() { + return Err(ProjectionError::Cancelled); + } + let mut parent = destination + .parent() + .ok_or(ProjectionError::InvalidRequest)? + .to_path_buf(); + let name = destination + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(ProjectionError::InvalidRequest)?; + let mut components = vec![PathBuf::from(name)]; + while fs::symlink_metadata(&parent).is_err() { + let name = parent + .file_name() + .filter(|name| !name.is_empty()) + .ok_or(ProjectionError::Unavailable)?; + components.push(PathBuf::from(name)); + parent = parent + .parent() + .ok_or(ProjectionError::Unavailable)? + .to_path_buf(); + } + if !fs::metadata(&parent) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + return Err(ProjectionError::Unavailable); + } + components.reverse(); + if components.iter().any(|component| { + component + .components() + .any(|part| !matches!(part, Component::Normal(_))) + }) { + return Err(ProjectionError::InvalidRequest); + } + let physical_anchor = + fs::canonicalize(&parent).map_err(|_| ProjectionError::Unavailable)?; + let metadata = fs::metadata(&physical_anchor).map_err(|_| ProjectionError::Unavailable)?; + let physical_destination = components + .iter() + .fold(physical_anchor.clone(), |path, component| { + path.join(component) + }); + targets.push(ProjectedTarget { + anchor_device: metadata.dev(), + anchor_inode: metadata.ino(), + physical_anchor, + physical_destination, + relative_components: components, + }); + } + Ok(ProjectionResponse { targets }) +} diff --git a/src-tauri/crates/environment-engine/src/source_inventory.rs b/src-tauri/crates/environment-engine/src/source_inventory.rs new file mode 100644 index 00000000..c14b434c --- /dev/null +++ b/src-tauri/crates/environment-engine/src/source_inventory.rs @@ -0,0 +1,427 @@ +use std::fmt; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::path::PathBuf; + +#[cfg(target_os = "linux")] +use std::fs; +#[cfg(target_os = "linux")] +use std::io::Read; + +#[cfg(target_os = "linux")] +const MAX_RECURSIVE_DEPTH: usize = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceInventoryRequest { + pub roots: Vec, + pub mode: SourceScanMode, + pub per_file_limit: u32, + pub aggregate_limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceRoot { + pub path: PathBuf, + pub stat_only: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceScanMode { + Recursive, + PriorityDirectories, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEntryKind { + Missing, + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEntryError { + PathUnavailable, + ReadFailed, + ReadLinkFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceEntry { + pub root_index: u32, + pub relative_path: PathBuf, + pub kind: SourceEntryKind, + pub link_target: Option, + pub content_bytes: Vec, + pub truncated: bool, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceInventoryResponse { + pub entries: Vec, + pub total_content_bytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceInventoryError { + UnsupportedPlatform, + InvalidRequest, + Cancelled, +} + +impl fmt::Display for SourceInventoryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for SourceInventoryError {} + +pub fn scan_source( + request: &SourceInventoryRequest, +) -> Result { + scan_source_with_cancel(request, || false) +} + +pub fn scan_source_with_cancel( + request: &SourceInventoryRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.roots.is_empty() + || request.roots.len() > u32::MAX as usize + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.roots.iter().any(|root| !root.path.is_absolute()) + { + return Err(SourceInventoryError::InvalidRequest); + } + scan_platform(request, &is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn scan_platform( + _request: &SourceInventoryRequest, + _is_cancelled: &impl Fn() -> bool, +) -> Result { + Err(SourceInventoryError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn scan_platform( + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, +) -> Result { + let mut entries = Vec::new(); + let mut total_content_bytes = 0usize; + for (root_index, root) in request.roots.iter().enumerate() { + ensure_not_cancelled(is_cancelled)?; + let read_root_content = root + .path + .file_name() + .is_some_and(|name| name == "skills-lock.json"); + let root_entry = inspect_entry( + &root.path, + root_index as u32, + PathBuf::new(), + read_root_content, + request, + &mut total_content_bytes, + ); + let root_is_directory = root_entry.kind == SourceEntryKind::Directory; + entries.push(root_entry); + if root.stat_only || !root_is_directory { + continue; + } + let visit_result = match request.mode { + SourceScanMode::Recursive => visit_recursive( + &root.path, + Path::new(""), + 1, + root_index as u32, + request, + is_cancelled, + &mut total_content_bytes, + &mut entries, + ), + SourceScanMode::PriorityDirectories => visit_priority_directories( + &root.path, + root_index as u32, + request, + is_cancelled, + &mut total_content_bytes, + &mut entries, + ), + }; + if let Err(error) = visit_result { + if error == SourceInventoryError::Cancelled { + return Err(error); + } + if let Some(root_entry) = entries.iter_mut().find(|entry| { + entry.root_index == root_index as u32 && entry.relative_path.as_os_str().is_empty() + }) { + root_entry.error = Some(SourceEntryError::PathUnavailable); + } + } + } + Ok(SourceInventoryResponse { + entries, + total_content_bytes: total_content_bytes as u32, + }) +} + +#[cfg(target_os = "linux")] +fn visit_priority_directories( + root: &Path, + root_index: u32, + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, + total_content_bytes: &mut usize, + entries: &mut Vec, +) -> Result<(), SourceInventoryError> { + let mut directories = read_directory(root)?; + directories.sort_by_key(fs::DirEntry::file_name); + for directory in directories { + ensure_not_cancelled(is_cancelled)?; + let metadata = match fs::symlink_metadata(directory.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + continue; + } + let mut children = read_directory(&directory.path())?; + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + ensure_not_cancelled(is_cancelled)?; + let file_name = child.file_name(); + if !file_name + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) + { + continue; + } + let relative_path = PathBuf::from(directory.file_name()).join(file_name); + let metadata = match fs::symlink_metadata(child.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.is_file() || metadata.file_type().is_symlink() { + entries.push(inspect_entry( + &child.path(), + root_index, + relative_path, + true, + request, + total_content_bytes, + )); + } + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_directory(directory: &Path) -> Result, SourceInventoryError> { + fs::read_dir(directory) + .map_err(|_| SourceInventoryError::InvalidRequest)? + .collect::, _>>() + .map_err(|_| SourceInventoryError::InvalidRequest) +} + +#[cfg(target_os = "linux")] +#[allow( + clippy::too_many_arguments, + reason = "recursive source scan carries one bounded accumulator" +)] +fn visit_recursive( + directory: &Path, + relative_parent: &Path, + depth: usize, + root_index: u32, + request: &SourceInventoryRequest, + is_cancelled: &impl Fn() -> bool, + total_content_bytes: &mut usize, + entries: &mut Vec, +) -> Result<(), SourceInventoryError> { + if depth > MAX_RECURSIVE_DEPTH { + return Ok(()); + } + let mut children = fs::read_dir(directory) + .map_err(|_| SourceInventoryError::InvalidRequest)? + .collect::, _>>() + .map_err(|_| SourceInventoryError::InvalidRequest)?; + children.sort_by_key(fs::DirEntry::file_name); + for child in children { + ensure_not_cancelled(is_cancelled)?; + let path = child.path(); + let relative_path = relative_parent.join(child.file_name()); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + if pruned_directory(child.file_name().as_os_str()) { + continue; + } + visit_recursive( + &path, + &relative_path, + depth + 1, + root_index, + request, + is_cancelled, + total_content_bytes, + entries, + )?; + continue; + } + if relevant_document(&relative_path) { + entries.push(inspect_entry( + &path, + root_index, + relative_path, + true, + request, + total_content_bytes, + )); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn relevant_document(relative_path: &Path) -> bool { + if relative_path == Path::new(".claude-plugin/marketplace.json") + || relative_path == Path::new(".claude-plugin/plugin.json") + || relative_path == Path::new("skills-lock.json") + { + return true; + } + relative_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) +} + +#[cfg(target_os = "linux")] +fn pruned_directory(name: &std::ffi::OsStr) -> bool { + matches!( + name.to_str(), + Some(".git" | "node_modules" | "dist" | "build" | "__pycache__" | "__pypackages__") + ) +} + +#[cfg(target_os = "linux")] +fn inspect_entry( + path: &Path, + root_index: u32, + relative_path: PathBuf, + read_content: bool, + request: &SourceInventoryRequest, + total_content_bytes: &mut usize, +) -> SourceEntry { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return empty_entry(root_index, relative_path, SourceEntryKind::Missing, None); + } + Err(_) => { + return empty_entry( + root_index, + relative_path, + SourceEntryKind::Other, + Some(SourceEntryError::PathUnavailable), + ); + } + }; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() { + SourceEntryKind::Symlink + } else if metadata.is_file() { + SourceEntryKind::File + } else if metadata.is_dir() { + SourceEntryKind::Directory + } else { + SourceEntryKind::Other + }; + let (link_target, mut error) = if kind == SourceEntryKind::Symlink { + match fs::read_link(path) { + Ok(target) => (Some(target), None), + Err(_) => (None, Some(SourceEntryError::ReadLinkFailed)), + } + } else { + (None, None) + }; + let mut content_bytes = Vec::new(); + let mut truncated = false; + let content_length = match kind { + SourceEntryKind::File => Some(metadata.len()), + SourceEntryKind::Symlink => fs::metadata(path) + .ok() + .filter(fs::Metadata::is_file) + .map(|target| target.len()), + _ => None, + }; + if let Some(content_length) = content_length.filter(|_| read_content) { + let remaining = (request.aggregate_limit as usize).saturating_sub(*total_content_bytes); + let limit = remaining.min(request.per_file_limit as usize); + match fs::File::open(path) { + Ok(file) => { + if file + .take(limit as u64) + .read_to_end(&mut content_bytes) + .is_ok() + { + *total_content_bytes += content_bytes.len(); + truncated = content_length > content_bytes.len() as u64; + } else { + content_bytes.clear(); + error = Some(SourceEntryError::ReadFailed); + } + } + Err(_) => error = Some(SourceEntryError::ReadFailed), + } + } + SourceEntry { + root_index, + relative_path, + kind, + link_target, + content_bytes, + truncated, + error, + } +} + +#[cfg(target_os = "linux")] +fn empty_entry( + root_index: u32, + relative_path: PathBuf, + kind: SourceEntryKind, + error: Option, +) -> SourceEntry { + SourceEntry { + root_index, + relative_path, + kind, + link_target: None, + content_bytes: Vec::new(), + truncated: false, + error, + } +} + +#[cfg(target_os = "linux")] +fn ensure_not_cancelled(is_cancelled: &impl Fn() -> bool) -> Result<(), SourceInventoryError> { + if is_cancelled() { + Err(SourceInventoryError::Cancelled) + } else { + Ok(()) + } +} diff --git a/src-tauri/crates/environment-engine/tests/document.rs b/src-tauri/crates/environment-engine/tests/document.rs new file mode 100644 index 00000000..dfa8548a --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/document.rs @@ -0,0 +1,117 @@ +#![cfg(target_os = "linux")] + +use environment_engine::document::{ + read_documents, remove_document_if_revision, write_document_atomic, DocumentQuery, + DocumentRequest, DocumentState, DocumentWriteError, +}; +use sha2::Digest; + +#[test] +fn conditional_document_write_replaces_a_file_and_returns_its_revision() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/projects.json"); + + let bytes = br#"{"projects":[]}"#; + let revision = write_document_atomic(&path, None, bytes).unwrap(); + let expected = format!("sha256:{:x}", sha2::Sha256::digest(bytes)); + assert_eq!(revision, expected); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + let replacement = br#"{"projects":["demo"]}"#; + let replacement_revision = write_document_atomic(&path, Some(&revision), replacement).unwrap(); + assert_eq!( + replacement_revision, + format!("sha256:{:x}", sha2::Sha256::digest(replacement)) + ); + assert_eq!(std::fs::read(&path).unwrap(), replacement); +} + +#[test] +fn conditional_document_remove_preserves_a_changed_target() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("applications/project.json"); + let revision = write_document_atomic(&path, None, b"current").unwrap(); + + assert_eq!( + remove_document_if_revision(&path, Some("sha256:wrong")).unwrap_err(), + DocumentWriteError::Conflict + ); + assert_eq!(std::fs::read(&path).unwrap(), b"current"); + + remove_document_if_revision(&path, Some(&revision)).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn conditional_document_write_rejects_a_changed_file_without_overwriting_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state/projects.json"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"old").unwrap(); + + assert_eq!( + write_document_atomic(&path, Some("sha256:wrong"), b"new").unwrap_err(), + DocumentWriteError::Conflict + ); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); +} + +#[test] +fn conditional_document_write_rejects_directory_and_symlink_targets() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("state"); + std::fs::create_dir(&directory).unwrap(); + assert_eq!( + write_document_atomic(&directory, None, b"new").unwrap_err(), + DocumentWriteError::InvalidTarget + ); + let target = temp.path().join("target"); + std::fs::write(&target, b"old").unwrap(); + let link = temp.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + write_document_atomic(&link, None, b"new").unwrap_err(), + DocumentWriteError::InvalidTarget + ); +} + +#[test] +fn optional_documents_are_bounded_and_isolated() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("document"); + let directory = temp.path().join("directory"); + std::fs::write(&file, b"abcdef").unwrap(); + std::fs::create_dir(&directory).unwrap(); + + let response = read_documents(&DocumentRequest { + queries: vec![ + DocumentQuery { + path: file, + limit: 4, + }, + DocumentQuery { + path: temp.path().join("missing"), + limit: 4, + }, + DocumentQuery { + path: directory, + limit: 4, + }, + ], + aggregate_limit: 8, + }) + .unwrap(); + + assert_eq!( + response.facts[0].state, + DocumentState::Bytes(b"abcd".to_vec()) + ); + assert!(response.facts[0].truncated); + assert_eq!(response.facts[1].state, DocumentState::Missing); + assert_eq!(response.facts[2].state, DocumentState::NotFile); + assert_eq!(response.total_content_bytes, 4); +} diff --git a/src-tauri/crates/environment-engine/tests/inspection.rs b/src-tauri/crates/environment-engine/tests/inspection.rs new file mode 100644 index 00000000..7bd40e9f --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/inspection.rs @@ -0,0 +1,190 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::symlink; + +use environment_engine::inspection::{ + inspect, inspect_with_cancel, EntryKind, ErrorCode, InspectionError, InspectionRequest, + InspectionRoot, +}; + +#[test] +fn inspects_direct_children_and_bounded_skill_documents() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("skills"); + std::fs::create_dir_all(root.join("Alpha")).expect("Alpha directory"); + std::fs::create_dir_all(root.join("beta")).expect("beta directory"); + std::fs::write(root.join("Alpha/SKILL.md"), b"123456").expect("Alpha document"); + std::fs::write(root.join("beta/SKILL.md"), b"abcdef").expect("beta document"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![InspectionRoot { + path: root, + stat_only: false, + }], + per_file_limit: 4, + aggregate_limit: 6, + }) + .expect("inspection"); + + assert_eq!(snapshot.total_content_bytes, 6); + let alpha = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("Alpha/SKILL.md")) + .expect("Alpha fact"); + assert_eq!(alpha.kind, EntryKind::File); + assert_eq!(alpha.content_bytes, b"1234"); + assert!(alpha.truncated); + + let beta = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("beta/SKILL.md")) + .expect("beta fact"); + assert_eq!(beta.content_bytes, b"ab"); + assert!(beta.truncated); +} + +#[test] +fn stat_only_root_does_not_enumerate_or_consume_content_budget() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("toolkit")).expect("toolkit directory"); + std::fs::write(temp.path().join("toolkit/SKILL.md"), b"document").expect("document"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: true, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + assert_eq!(snapshot.facts.len(), 1); + assert_eq!(snapshot.facts[0].kind, EntryKind::Directory); + assert_eq!(snapshot.total_content_bytes, 0); +} + +#[test] +fn follows_a_child_directory_symlink_only_for_its_skill_document() { + let temp = tempfile::tempdir().expect("tempdir"); + let canonical = temp.path().join("canonical/toolkit"); + let root = temp.path().join("skills"); + std::fs::create_dir_all(&canonical).expect("canonical directory"); + std::fs::create_dir_all(&root).expect("skills directory"); + std::fs::write(canonical.join("SKILL.md"), b"document").expect("document"); + symlink(&canonical, root.join("toolkit")).expect("directory symlink"); + symlink(temp.path().join("missing"), root.join("broken")).expect("broken symlink"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![InspectionRoot { + path: root, + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + let linked = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_path() == std::path::Path::new("toolkit")) + .expect("linked directory"); + assert_eq!(linked.kind, EntryKind::Symlink); + assert_eq!(linked.resolved_target.as_deref(), Some(canonical.as_path())); + assert!(snapshot + .facts + .iter() + .any(|fact| fact.relative_path.as_path() == std::path::Path::new("toolkit/SKILL.md"))); + assert!(!snapshot + .facts + .iter() + .any(|fact| fact.relative_path.as_path() == std::path::Path::new("broken/SKILL.md"))); +} + +#[test] +fn preserves_non_utf8_relative_paths_without_lossy_conversion() { + let temp = tempfile::tempdir().expect("tempdir"); + let raw_name = std::ffi::OsString::from_vec(vec![b's', b'k', 0x80]); + std::fs::write(temp.path().join(&raw_name), b"payload").expect("non-UTF-8 entry"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }) + .expect("inspection"); + + let fact = snapshot + .facts + .iter() + .find(|fact| fact.relative_path.as_os_str().as_bytes() == raw_name.as_bytes()) + .expect("raw path fact"); + assert_eq!(fact.kind, EntryKind::File); +} + +#[test] +fn isolates_missing_and_unreadable_roots() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing"); + let file = temp.path().join("file"); + std::fs::write(&file, b"content").expect("file"); + + let snapshot = inspect(&InspectionRequest { + roots: vec![ + InspectionRoot { + path: missing, + stat_only: false, + }, + InspectionRoot { + path: file, + stat_only: false, + }, + ], + per_file_limit: 16, + aggregate_limit: 32, + }) + .expect("inspection"); + + assert_eq!(snapshot.facts[0].kind, EntryKind::Missing); + assert_eq!(snapshot.facts[0].error_code, None); + assert_eq!(snapshot.facts[1].kind, EntryKind::File); + assert_ne!( + snapshot.facts[1].error_code, + Some(ErrorCode::PathUnavailable) + ); +} + +#[test] +fn cooperative_cancellation_stops_between_filesystem_entries() { + let temp = tempfile::tempdir().expect("tempdir"); + for index in 0..32 { + std::fs::create_dir_all(temp.path().join(format!("skill-{index}"))) + .expect("skill directory"); + } + let checks = std::cell::Cell::new(0usize); + + let error = inspect_with_cancel( + &InspectionRequest { + roots: vec![InspectionRoot { + path: temp.path().to_path_buf(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + }, + || { + checks.set(checks.get() + 1); + checks.get() > 2 + }, + ) + .unwrap_err(); + + assert_eq!(error, InspectionError::Cancelled); +} diff --git a/src-tauri/crates/environment-engine/tests/library.rs b/src-tauri/crates/environment-engine/tests/library.rs new file mode 100644 index 00000000..396f5adb --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/library.rs @@ -0,0 +1,239 @@ +#![cfg(target_os = "linux")] + +use std::path::Path; + +use environment_engine::library::{ + commit, read_catalog, write_catalog, CatalogWrite, ContentAction, LibraryCommit, LibraryError, + TargetExpectation, +}; +use environment_engine::linux_mutation::fingerprint_path; +use environment_engine::projection::{project_targets, ProjectionRequest}; + +#[test] +fn member_upsert_commits_content_and_catalog_as_one_intent() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + let payload = payload_fixture(temp.path(), b"new content"); + let catalog = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "operation-1".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Upsert { + payload_root: payload, + }, + catalog: CatalogWrite { + expected_revision: None, + bytes: catalog.clone(), + }, + }) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"new content" + ); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert!(!root.join(".transactions/operation-1").exists()); +} + +#[test] +fn member_commit_accepts_a_managed_root_reached_through_a_symlinked_parent() { + let temp = tempfile::tempdir().unwrap(); + let physical_home = temp.path().join("physical-home"); + std::fs::create_dir(&physical_home).unwrap(); + let alias_home = temp.path().join("home"); + std::os::unix::fs::symlink(&physical_home, &alias_home).unwrap(); + let root = alias_home.join(".skill-deck/skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + let payload = payload_fixture(temp.path(), b"linked home"); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "linked-home".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Upsert { + payload_root: payload, + }, + catalog: CatalogWrite { + expected_revision: None, + bytes: br#"{"schemaVersion":1}"#.to_vec(), + }, + }) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"linked home" + ); +} + +#[test] +fn catalog_write_is_conditional_and_creates_declared_library_roots() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let first = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + let revision = write_catalog( + &root, + &[], + CatalogWrite { + expected_revision: None, + bytes: first, + }, + ) + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + + write_catalog( + &root, + &["lib-1".to_string()], + CatalogWrite { + expected_revision: Some(revision), + bytes: second.clone(), + }, + ) + .unwrap(); + assert!(root.join("libraries/lib-1/skills").is_dir()); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second.clone())); + + assert!(matches!( + write_catalog( + &root, + &[], + CatalogWrite { + expected_revision: Some("sha256:wrong".to_string()), + bytes: b"stale".to_vec(), + }, + ), + Err(LibraryError::StaleTarget) + )); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second)); +} + +#[test] +fn delete_commit_removes_the_destination_and_updates_catalog() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":["demo"]}]}"#.to_vec(); + let revision = write_catalog( + &root, + &["lib-1".to_string()], + CatalogWrite { + expected_revision: None, + bytes: first, + }, + ) + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":[]}]}"#.to_vec(); + + commit(LibraryCommit { + root: root.clone(), + operation_id: "delete-1".to_string(), + destination: destination.clone(), + expected_target: target_expectation(&destination), + content: ContentAction::Delete, + catalog: CatalogWrite { + expected_revision: Some(revision), + bytes: second.clone(), + }, + }) + .unwrap(); + + assert!(!destination.exists()); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(second)); + assert!(!root.join(".transactions/delete-1").exists()); +} + +#[test] +fn catalog_read_recovers_an_activated_legacy_wsl_transaction() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + let transaction = root.join(".transactions/interrupted"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"new").unwrap(); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_encoded_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("phase"), b"activated").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(!transaction.exists()); +} + +#[test] +fn catalog_read_keeps_legacy_prepared_content_when_catalog_hash_matches() { + use sha2::{Digest, Sha256}; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let destination = root.join("libraries/lib-1/skills/demo"); + let transaction = root.join(".transactions/committed"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"new").unwrap(); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_encoded_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogPrepared").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + std::fs::write( + transaction.join("expected-catalog-hash"), + format!("{:x}", Sha256::digest(&catalog)), + ) + .unwrap(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + + read_catalog(&root).unwrap(); + + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + assert!(!transaction.exists()); +} + +fn target_expectation(destination: &Path) -> TargetExpectation { + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.to_path_buf()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + TargetExpectation { + parent: environment_engine::linux_mutation::ParentIdentity { + device: target.anchor_device, + inode: target.anchor_inode, + }, + fingerprint: fingerprint_path(destination).unwrap(), + content_hash: None, + } +} + +fn payload_fixture(parent: &Path, content: &[u8]) -> std::path::PathBuf { + let source = parent.join("source"); + let payload = parent.join("payload"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), content).unwrap(); + environment_engine::payload::build_payload(&source, &payload).unwrap(); + payload +} diff --git a/src-tauri/crates/environment-engine/tests/linux_mutation.rs b/src-tauri/crates/environment-engine/tests/linux_mutation.rs new file mode 100644 index 00000000..e4029bcf --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/linux_mutation.rs @@ -0,0 +1,378 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, EntryAction, EntryIntent, MutationError, + StagedMutation, +}; +use environment_engine::payload::build_payload; + +#[test] +fn replace_rechecks_swaps_verifies_and_restores_one_linux_entry_set() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + + let mut staged = StagedMutation::stage("operation-1", vec![intent], || false).unwrap(); + staged.recheck(|| false).unwrap(); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + staged.restore().unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + staged.cleanup().unwrap(); +} + +#[test] +fn materialize_keeps_an_unselected_agent_with_a_missing_root_absent() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let canonical = scope.join(".agents/skills/demo"); + let unselected = scope.join(".opencode/skills/demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&scope).unwrap(); + let intents = vec![ + EntryIntent { + destination: canonical.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&canonical).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: unselected.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&unselected).unwrap(), + expected_content_hash: None, + action: EntryAction::Keep, + }, + ]; + + let mut staged = StagedMutation::stage("operation-keep", intents, || false).unwrap(); + + assert!(!unselected.parent().unwrap().exists()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!(std::fs::read(canonical.join("SKILL.md")).unwrap(), b"new"); + assert!(!unselected.parent().unwrap().exists()); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn materialize_creates_a_selected_agent_root_and_installs_its_symlink() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let canonical = scope.join(".agents/skills/demo"); + let selected = scope.join(".claude/skills/demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&scope).unwrap(); + let intents = vec![ + EntryIntent { + destination: canonical.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&canonical).unwrap(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: selected.clone(), + expected_parent: anchor, + expected_fingerprint: fingerprint_path(&selected).unwrap(), + expected_content_hash: None, + action: EntryAction::Symlink { + target: std::path::PathBuf::from("../../.agents/skills/demo"), + }, + }, + ]; + + let mut staged = StagedMutation::stage("operation-symlink", intents, || false).unwrap(); + + assert!(selected.parent().unwrap().is_dir()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert_eq!( + std::fs::read_link(&selected).unwrap(), + std::path::Path::new("../../.agents/skills/demo") + ); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn keep_rejects_a_missing_target_created_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let destination = scope.join(".opencode/skills/demo"); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(&scope).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Keep, + }; + let mut staged = StagedMutation::stage("operation-stale-keep", vec![intent], || false).unwrap(); + std::fs::create_dir_all(&destination).unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn a_later_stage_failure_cleans_every_prepared_entry() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let first = parent.join("first"); + let second = parent.join("second"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let anchor = parent_identity(&parent).unwrap(); + let intents = vec![ + EntryIntent { + destination: first, + expected_parent: anchor, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }, + EntryIntent { + destination: second, + expected_parent: anchor, + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: temp.path().join("missing-payload"), + }, + }, + ]; + + assert_eq!( + StagedMutation::stage("operation-cleanup", intents, || false).unwrap_err(), + MutationError::InvalidPayload + ); + assert!(!parent + .join(".skill-deck-stage-operation-cleanup-000000") + .exists()); + assert!(!parent + .join(".skill-deck-stage-operation-cleanup-000001") + .exists()); +} + +#[test] +fn staging_never_cleans_a_preexisting_operation_path() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let destination = parent.join("demo"); + let existing_stage = parent.join(".skill-deck-stage-operation-existing-000000"); + std::fs::create_dir(&existing_stage).unwrap(); + std::fs::write(existing_stage.join("evidence"), b"keep").unwrap(); + let intent = EntryIntent { + destination, + expected_parent: parent_identity(&parent).unwrap(), + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Remove, + }; + + assert_eq!( + StagedMutation::stage("operation-existing", vec![intent], || false).unwrap_err(), + MutationError::StaleTarget + ); + assert_eq!( + std::fs::read(existing_stage.join("evidence")).unwrap(), + b"keep" + ); +} + +#[test] +fn stage_only_cleanup_preserves_backup_evidence() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("targets"); + std::fs::create_dir(&parent).unwrap(); + let destination = parent.join("demo"); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination, + expected_parent: parent_identity(&parent).unwrap(), + expected_fingerprint: "entry-v1-missing".to_string(), + expected_content_hash: None, + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-stage-only", vec![intent], || false).unwrap(); + let stage = parent.join(".skill-deck-stage-operation-stage-only-000000"); + let backup = parent.join(".skill-deck-backup-operation-stage-only-000000"); + std::fs::create_dir(&backup).unwrap(); + std::fs::write(backup.join("evidence"), b"old").unwrap(); + + assert!(staged.cleanup_stages().is_empty()); + + assert!(!stage.exists()); + assert_eq!(std::fs::read(backup.join("evidence")).unwrap(), b"old"); +} + +#[test] +fn remove_keeps_a_missing_target_parent_absent() { + let temp = tempfile::tempdir().unwrap(); + let scope = temp.path().join("scope"); + std::fs::create_dir(&scope).unwrap(); + let destination = scope.join(".opencode/skills/demo"); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(&scope).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Remove, + }; + + let mut staged = StagedMutation::stage("operation-remove", vec![intent], || false).unwrap(); + + assert!(!destination.parent().unwrap().exists()); + staged.swap(|| false).unwrap(); + staged.verify(|| false).unwrap(); + assert!(!destination.parent().unwrap().exists()); + staged.restore().unwrap(); + staged.cleanup().unwrap(); +} + +#[test] +fn replace_rejects_a_target_changed_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-2", vec![intent], || false).unwrap(); + std::fs::write(destination.join("external"), b"changed").unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn replace_rejects_existing_file_content_changed_after_stage() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let payload = temp.path().join("payload"); + build_payload(&source, &payload).unwrap(); + let intent = EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: EntryAction::Materialize { + payload_root: payload, + }, + }; + let mut staged = StagedMutation::stage("operation-3", vec![intent], || false).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"locally changed").unwrap(); + + assert_eq!( + staged.swap(|| false).unwrap_err(), + MutationError::StaleTarget + ); + staged.cleanup().unwrap(); +} + +#[test] +fn cancellation_is_delayed_after_the_entry_set_starts_swapping() { + let temp = tempfile::tempdir().unwrap(); + let destinations = [ + temp.path().join("targets/one"), + temp.path().join("targets/two"), + ]; + let intents = destinations + .iter() + .map(|destination| { + std::fs::create_dir_all(destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + EntryIntent { + destination: destination.clone(), + expected_parent: parent_identity(destination.parent().unwrap()).unwrap(), + expected_fingerprint: fingerprint_path(destination).unwrap(), + expected_content_hash: None, + action: EntryAction::Remove, + } + }) + .collect(); + let mut staged = StagedMutation::stage("operation-4", intents, || false).unwrap(); + let checks = std::cell::Cell::new(0_u32); + + staged + .swap(|| { + checks.set(checks.get() + 1); + checks.get() >= 4 + }) + .unwrap(); + + assert!(destinations.iter().all(|destination| !destination.exists())); + staged.verify(|| false).unwrap(); + staged.restore().unwrap(); + assert!(destinations.iter().all(|destination| destination.is_dir())); + staged.cleanup().unwrap(); +} diff --git a/src-tauri/crates/environment-engine/tests/lock.rs b/src-tauri/crates/environment-engine/tests/lock.rs new file mode 100644 index 00000000..3a7deed8 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/lock.rs @@ -0,0 +1,81 @@ +use std::collections::BTreeMap; + +use environment_engine::lock::{apply, EntryMutation, LockError, LockMutation, LockSchema}; +use serde_json::json; + +#[test] +fn conditional_lock_apply_preserves_unknown_fields_and_returns_new_evidence() { + let current = serde_json::to_vec(&json!({ + "version": 3, + "skills": { + "demo": { + "source": "old", + "skillFolderHash": "old-hash", + "futureEntry": { "keep": true } + }, + "other": { "source": "untouched" } + }, + "futureRoot": [1, 2, 3] + })) + .unwrap(); + let mutation = LockMutation { + schema: LockSchema::Global, + entry: EntryMutation::Replace { + key: "demo".to_string(), + replacement: json!({ + "source": "new", + "skillFolderHash": "new-hash" + }), + }, + root_replacements: BTreeMap::new(), + expected_entries: BTreeMap::from([( + "demo".to_string(), + Some(json!({ + "source": "old", + "skillFolderHash": "old-hash", + "futureEntry": { "keep": true } + })), + )]), + expected_roots: BTreeMap::new(), + }; + + let applied = apply(Some(¤t), None, &mutation).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&applied.bytes).unwrap(); + + assert_eq!(value["skills"]["demo"]["source"], "new"); + assert_eq!(value["skills"]["demo"]["futureEntry"]["keep"], true); + assert_eq!(value["skills"]["other"]["source"], "untouched"); + assert_eq!(value["futureRoot"], json!([1, 2, 3])); + assert_eq!( + applied.receipt.entries["demo"], + Some(value["skills"]["demo"].clone()) + ); +} + +#[test] +fn conditional_lock_apply_rejects_changed_selected_entry() { + let current = serde_json::to_vec(&json!({ + "version": 1, + "skills": { "demo": { "source": "changed" } } + })) + .unwrap(); + let mutation = LockMutation { + schema: LockSchema::Project, + entry: EntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected_entries: BTreeMap::from([( + "demo".to_string(), + Some(json!({ "source": "expected" })), + )]), + expected_roots: BTreeMap::new(), + }; + + assert_eq!( + apply(Some(¤t), None, &mutation).unwrap_err(), + LockError::EntryConflict { + key: "demo".to_string() + } + ); +} diff --git a/src-tauri/crates/environment-engine/tests/path_and_directory.rs b/src-tauri/crates/environment-engine/tests/path_and_directory.rs new file mode 100644 index 00000000..daf288b5 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/path_and_directory.rs @@ -0,0 +1,91 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::symlink; + +use environment_engine::directory::{ + count_entries, list_child_directories, DirectoryCountRequest, DirectoryListRequest, +}; +use environment_engine::path::{inspect_paths, ContentState, PathKind, PathQuery, PathRequest}; + +#[test] +fn path_metadata_classifies_links_and_reads_bounded_content() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("directory"); + let file = temp.path().join("package.json"); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(&file, b"abcdef").unwrap(); + symlink(&directory, temp.path().join("directory-link")).unwrap(); + symlink(&file, temp.path().join("file-link")).unwrap(); + symlink(temp.path().join("missing"), temp.path().join("broken")).unwrap(); + + let response = inspect_paths(&PathRequest { + queries: vec![ + PathQuery { + path: directory, + content_limit: None, + }, + PathQuery { + path: temp.path().join("directory-link"), + content_limit: None, + }, + PathQuery { + path: temp.path().join("file-link"), + content_limit: Some(4), + }, + PathQuery { + path: temp.path().join("broken"), + content_limit: None, + }, + ], + aggregate_content_limit: 8, + }) + .unwrap(); + + assert_eq!(response.facts[0].kind, PathKind::Directory); + assert_eq!(response.facts[1].kind, PathKind::SymlinkDirectory); + assert_eq!(response.facts[2].kind, PathKind::SymlinkOther); + assert_eq!( + response.facts[2].content, + ContentState::Bytes(b"abcd".to_vec()) + ); + assert!(response.facts[2].content_truncated); + assert_eq!(response.facts[3].kind, PathKind::BrokenLink); +} + +#[test] +fn directory_count_isolates_missing_paths_and_caps_entries() { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("skills"); + std::fs::create_dir(&directory).unwrap(); + for index in 0..4 { + std::fs::write(directory.join(format!("skill-{index}")), b"skill").unwrap(); + } + + let response = count_entries(&DirectoryCountRequest { + paths: vec![directory, temp.path().join("missing")], + limit: 3, + }) + .unwrap(); + + assert_eq!(response.facts[0].observed_count, Some(3)); + assert!(response.facts[0].truncated); + assert_eq!(response.facts[1].observed_count, None); + assert!(!response.facts[1].truncated); +} + +#[test] +fn child_directory_listing_is_sorted_and_bounded() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("beta")).unwrap(); + std::fs::create_dir(temp.path().join("Alpha")).unwrap(); + std::fs::write(temp.path().join("file"), b"file").unwrap(); + + let response = list_child_directories(&DirectoryListRequest { + path: temp.path().to_path_buf(), + limit: 1, + }) + .unwrap(); + + assert_eq!(response.names, vec![std::path::PathBuf::from("Alpha")]); + assert!(response.truncated); +} diff --git a/src-tauri/crates/environment-engine/tests/payload.rs b/src-tauri/crates/environment-engine/tests/payload.rs new file mode 100644 index 00000000..0fc1a1f5 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/payload.rs @@ -0,0 +1,125 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::{symlink, PermissionsExt}; + +use environment_engine::payload::{ + build_payload, source_metadata_fingerprint, verify_payload, PayloadEntryKind, PayloadError, +}; + +#[test] +fn payload_build_preserves_linux_content_semantics_without_loading_duplicate_blobs() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let payload_root = temp.path().join("payload"); + std::fs::create_dir_all(source.join("scripts")).unwrap(); + std::fs::create_dir_all(source.join("assets")).unwrap(); + std::fs::create_dir_all(source.join(".git")).unwrap(); + std::fs::write(source.join("SKILL.md"), b"skill").unwrap(); + std::fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); + std::fs::write(source.join("assets/copy.txt"), b"skill").unwrap(); + std::fs::write(source.join("metadata.json"), b"excluded").unwrap(); + std::fs::write(source.join(".git/config"), b"excluded").unwrap(); + std::fs::set_permissions( + source.join("scripts/run.sh"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + + let built = build_payload(&source, &payload_root).unwrap(); + + assert_eq!(built.total_bytes, 15); + assert_eq!(built.manifest.payload_id, built.manifest.payload_root_hash); + assert_eq!( + std::fs::read_dir(payload_root.join("blobs")) + .unwrap() + .count(), + 2 + ); + assert!(built.manifest.entries.iter().any(|entry| { + entry.relative_path == "scripts/run.sh" + && entry.kind == PayloadEntryKind::File + && entry.executable + })); + assert!(!built.manifest.entries.iter().any( + |entry| entry.relative_path.contains(".git") || entry.relative_path == "metadata.json" + )); + assert_eq!(verify_payload(&payload_root).unwrap(), built.manifest); +} + +#[test] +fn payload_build_rejects_links_outside_the_selected_source() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(temp.path().join("outside"), b"outside").unwrap(); + symlink(temp.path().join("outside"), source.join("linked")).unwrap(); + + assert!(matches!( + build_payload(&source, &temp.path().join("payload")), + Err(PayloadError::UnsafeSourceLink { .. }) + )); +} + +#[test] +fn source_fingerprint_tracks_linux_metadata_and_rejects_external_links() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let before = source_metadata_fingerprint(&source).unwrap(); + + let mut permissions = std::fs::metadata(source.join("SKILL.md")) + .unwrap() + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(source.join("SKILL.md"), permissions).unwrap(); + let after = source_metadata_fingerprint(&source).unwrap(); + assert_ne!(before, after); + + std::fs::write(temp.path().join("outside"), b"outside").unwrap(); + symlink(temp.path().join("outside"), source.join("external")).unwrap(); + assert!(matches!( + source_metadata_fingerprint(&source), + Err(PayloadError::UnsafeSourceLink { .. }) + )); +} + +#[test] +fn cancelled_payload_build_removes_its_partial_destination() { + use environment_engine::payload::build_payload_with_cancel; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + let payload = temp.path().join("payload"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), vec![7_u8; 128 * 1024]).unwrap(); + + let checks = std::cell::Cell::new(0_u32); + let result = build_payload_with_cancel(&source, &payload, || { + checks.set(checks.get() + 1); + checks.get() > 2 + }); + + assert!(matches!(result, Err(PayloadError::Cancelled))); + assert!(!payload.exists()); +} + +#[test] +fn payload_build_rejects_non_utf8_manifest_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write( + source.join(OsString::from_vec(vec![b's', 0xff])), + b"content", + ) + .unwrap(); + + assert!(matches!( + build_payload(&source, &temp.path().join("payload")), + Err(PayloadError::InvalidSource) + )); +} diff --git a/src-tauri/crates/environment-engine/tests/planning.rs b/src-tauri/crates/environment-engine/tests/planning.rs new file mode 100644 index 00000000..c570852d --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/planning.rs @@ -0,0 +1,81 @@ +#![cfg(target_os = "linux")] + +use std::os::unix::fs::{symlink, PermissionsExt}; + +use environment_engine::entry::{inspect_entries, EntryKind, EntryRequest}; +use environment_engine::manifest::{build_manifest, ManifestKind, ManifestRequest}; +use environment_engine::projection::{project_targets, ProjectionRequest}; + +#[test] +fn entry_facts_do_not_follow_the_final_symlink() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + std::fs::write(&target, b"first").unwrap(); + symlink(&target, &link).unwrap(); + + let before = inspect_entries(&EntryRequest { + paths: vec![link.clone()], + }) + .unwrap(); + std::fs::write(&target, b"changed target").unwrap(); + let after = inspect_entries(&EntryRequest { paths: vec![link] }).unwrap(); + + assert_eq!(before.facts[0].kind, EntryKind::Symlink); + assert_eq!(before.facts[0], after.facts[0]); +} + +#[test] +fn projection_resolves_existing_ancestors_before_appending_components() { + let temp = tempfile::tempdir().unwrap(); + let physical = temp.path().join("physical"); + let logical = temp.path().join("logical"); + std::fs::create_dir(&physical).unwrap(); + symlink(&physical, &logical).unwrap(); + let destination = logical.join("skills/demo"); + + let response = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap(); + + assert_eq!( + response.targets[0].physical_destination, + physical.join("skills/demo") + ); + assert_eq!( + response.targets[0].relative_components, + vec![ + std::path::PathBuf::from("skills"), + std::path::PathBuf::from("demo"), + ] + ); + assert!(!destination.exists()); +} + +#[test] +fn manifest_captures_digest_executable_directories_and_symlinks() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill"); + std::fs::create_dir_all(root.join("empty")).unwrap(); + std::fs::write(root.join("run.sh"), b"#!/bin/sh\n").unwrap(); + let mut permissions = std::fs::metadata(root.join("run.sh")) + .unwrap() + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(root.join("run.sh"), permissions).unwrap(); + symlink("run.sh", root.join("current")).unwrap(); + + let response = build_manifest(&ManifestRequest { root }).unwrap(); + + assert!(response.records.iter().any(|record| { + record.relative_path.as_path() == std::path::Path::new("run.sh") + && record.kind == ManifestKind::File + && record.executable + })); + assert!(response.records.iter().any(|record| { + record.relative_path.as_path() == std::path::Path::new("current") + && record.kind == ManifestKind::Symlink + && record.symlink_target.as_deref() == Some(std::path::Path::new("run.sh")) + })); +} diff --git a/src-tauri/crates/environment-engine/tests/source_inventory.rs b/src-tauri/crates/environment-engine/tests/source_inventory.rs new file mode 100644 index 00000000..50391066 --- /dev/null +++ b/src-tauri/crates/environment-engine/tests/source_inventory.rs @@ -0,0 +1,91 @@ +#![cfg(target_os = "linux")] + +use environment_engine::source_inventory::{ + scan_source, SourceEntryKind, SourceInventoryRequest, SourceRoot, SourceScanMode, +}; + +#[test] +fn recursive_inventory_reads_only_source_documents_and_prunes_dependency_trees() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(root.join("skills/demo")).unwrap(); + std::fs::create_dir_all(root.join("skills/linked")).unwrap(); + std::fs::create_dir_all(root.join("node_modules/ignored")).unwrap(); + std::fs::create_dir_all(root.join(".claude-plugin")).unwrap(); + std::fs::write(root.join("skills/demo/SKILL.md"), b"demo").unwrap(); + std::fs::write(root.join("linked-document"), b"linked-demo").unwrap(); + std::os::unix::fs::symlink( + root.join("linked-document"), + root.join("skills/linked/SKILL.md"), + ) + .unwrap(); + std::fs::write(root.join("node_modules/ignored/SKILL.md"), b"ignored").unwrap(); + std::fs::write(root.join(".claude-plugin/plugin.json"), b"plugin").unwrap(); + std::fs::write(root.join("skills-lock.json"), b"lock").unwrap(); + std::fs::write(root.join("ordinary.txt"), b"ordinary").unwrap(); + + let response = scan_source(&SourceInventoryRequest { + roots: vec![SourceRoot { + path: root, + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 16, + aggregate_limit: 64, + }) + .unwrap(); + + let documents = response + .entries + .iter() + .filter(|entry| !entry.relative_path.as_os_str().is_empty()) + .map(|entry| { + ( + entry.relative_path.to_string_lossy().into_owned(), + entry.content_bytes.clone(), + ) + }) + .collect::>(); + assert_eq!(documents["skills/demo/SKILL.md"], b"demo"); + assert_eq!(documents["skills/linked/SKILL.md"], b"linked-demo"); + assert_eq!(documents[".claude-plugin/plugin.json"], b"plugin"); + assert_eq!(documents["skills-lock.json"], b"lock"); + assert!(!documents.contains_key("node_modules/ignored/SKILL.md")); + assert!(!documents.contains_key("ordinary.txt")); + assert_eq!(response.entries[0].kind, SourceEntryKind::Directory); +} + +#[test] +fn priority_inventory_reads_only_direct_child_skill_documents() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("catalog"); + let linked_source = temp.path().join("linked-source"); + std::fs::create_dir_all(root.join("direct/scripts")).unwrap(); + std::fs::create_dir_all(root.join("category/nested")).unwrap(); + std::fs::create_dir_all(&linked_source).unwrap(); + std::fs::write(root.join("direct/skill.MD"), b"direct").unwrap(); + std::fs::write(root.join("direct/scripts/SKILL.md"), b"too-deep").unwrap(); + std::fs::write(root.join("category/nested/SKILL.md"), b"nested").unwrap(); + std::fs::write(linked_source.join("SKILL.md"), b"linked").unwrap(); + symlink(linked_source, root.join("linked")).unwrap(); + + let response = scan_source(&SourceInventoryRequest { + roots: vec![SourceRoot { + path: root, + stat_only: false, + }], + mode: SourceScanMode::PriorityDirectories, + per_file_limit: 16, + aggregate_limit: 64, + }) + .unwrap(); + + let relative_paths = response + .entries + .iter() + .map(|entry| entry.relative_path.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(relative_paths, vec!["", "direct/skill.MD"]); +} From 4cb3c857707453ada73138a9ec9f8325d3eafe62 Mon Sep 17 00:00:00 2001 From: Hccake Date: Wed, 2 Sep 2026 22:36:27 +0800 Subject: [PATCH 03/27] feat(wsl): add the supervised environment worker runtime --- src-tauri/Cargo.lock | 139 +- src-tauri/Cargo.toml | 17 +- .../crates/wsl-environment-worker/Cargo.toml | 20 + .../src/inbound_transfer.rs | 130 + .../crates/wsl-environment-worker/src/lib.rs | 857 +++++++ .../wsl-environment-worker/src/library.rs | 181 ++ .../crates/wsl-environment-worker/src/main.rs | 2212 +++++++++++++++++ .../wsl-environment-worker/src/mutation.rs | 949 +++++++ .../wsl-environment-worker/src/payload.rs | 657 +++++ .../wsl-environment-worker/src/source.rs | 593 +++++ .../tests/inbound_transfer.rs | 38 + .../wsl-environment-worker/tests/library.rs | 108 + .../tests/mutation_recovery.rs | 96 + .../tests/mutation_transaction.rs | 374 +++ .../wsl-environment-worker/tests/payload.rs | 103 + .../wsl-environment-worker/tests/runtime.rs | 135 + .../wsl-environment-worker/tests/source.rs | 148 ++ .../wsl-environment-worker/tests/stdio.rs | 1161 +++++++++ .../src/application/environment_settings.rs | 1 - src-tauri/src/environment/wsl.rs | 1125 ++++++--- src-tauri/src/environment/wsl/protocol.rs | 429 +--- .../src/environment/wsl/scripts/session.sh | 46 +- src-tauri/src/environment/wsl/worker.rs | 2018 +++++++++++++++ src-tauri/src/error.rs | 5 +- src-tauri/src/runtime/mod.rs | 4 +- src-tauri/src/runtime/network_connection.rs | 9 +- 26 files changed, 10820 insertions(+), 735 deletions(-) create mode 100644 src-tauri/crates/wsl-environment-worker/Cargo.toml create mode 100644 src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/lib.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/library.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/main.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/mutation.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/payload.rs create mode 100644 src-tauri/crates/wsl-environment-worker/src/source.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/library.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/payload.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/runtime.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/source.rs create mode 100644 src-tauri/crates/wsl-environment-worker/tests/stdio.rs create mode 100644 src-tauri/src/environment/wsl/worker.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a9eedb51..8f21a455 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -99,13 +99,14 @@ dependencies = [ "chrono", "dirs", "embed-resource", + "environment-engine", + "environment-protocol", "flate2", + "futures-util", "junction", "keyring", "libc", "log", - "once_cell", - "pathdiff", "regex", "reqwest", "scraper", @@ -129,6 +130,7 @@ dependencies = [ "thiserror 2.0.18", "tiny_http", "tokio", + "tokio-util", "unicode-normalization", "url", "urlencoding", @@ -324,6 +326,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -718,6 +729,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "combine" version = "4.6.7" @@ -817,6 +837,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1144,6 +1170,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "endi" version = "1.1.1" @@ -1181,6 +1219,32 @@ dependencies = [ "regex", ] +[[package]] +name = "environment-engine" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "tempfile", + "unicode-normalization", +] + +[[package]] +name = "environment-protocol" +version = "0.0.0" +dependencies = [ + "bytes", + "futures-util", + "postcard", + "serde", + "serde_bytes", + "sha2", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1754,6 +1818,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1769,6 +1842,20 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3364,6 +3451,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4222,6 +4322,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4556,6 +4666,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6537,6 +6656,22 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wsl-environment-worker" +version = "0.0.0" +dependencies = [ + "environment-engine", + "environment-protocol", + "futures-util", + "libc", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-util", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 94289651..05982da9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,10 @@ serde_yaml = "0.9" thiserror = "2" dirs = "6" keyring = "4.1.5" +environment-protocol = { path = "crates/environment-protocol" } +environment-engine = { path = "crates/environment-engine" } +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["codec"] } # 临时目录 tempfile = "3" @@ -45,9 +49,6 @@ tempfile = "3" # HTTP 请求 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# 计算相对路径 -pathdiff = "0.2" - # 递归目录遍历 walkdir = "2" @@ -64,8 +65,6 @@ urlencoding = "2" # 时间处理 chrono = { version = "0.4", features = ["serde"] } -# 延迟初始化单例 -once_cell = "1" unicode-normalization = "0.1" sha2 = "0.10.9" tauri-plugin-opener = "2.5.3" @@ -87,3 +86,11 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Secur tempfile = "3" tauri = { version = "2.10.0", features = ["test"] } tiny_http = "0.12" + +[workspace] +members = [ + "crates/environment-engine", + "crates/environment-protocol", + "crates/wsl-environment-worker", +] +resolver = "2" diff --git a/src-tauri/crates/wsl-environment-worker/Cargo.toml b/src-tauri/crates/wsl-environment-worker/Cargo.toml new file mode 100644 index 00000000..64c3c554 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "wsl-environment-worker" +version = "0.0.0" +edition = "2021" +rust-version = "1.88.0" +publish = false + +[dependencies] +environment-engine = { path = "../environment-engine" } +environment-protocol = { path = "../environment-protocol" } +futures-util = "0.3" +libc = "0.2" +serde = "1" +serde_json = "1" +sha2 = "0.10.9" +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } +tokio-util = { version = "0.7", features = ["codec"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs b/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs new file mode 100644 index 00000000..ca90a8e5 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/inbound_transfer.rs @@ -0,0 +1,130 @@ +use std::fmt; + +use environment_protocol::MAX_PAYLOAD_CHUNK_BYTES; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferDeclaration { + pub owner_request_id: u64, + pub transfer_id: u64, + pub total_bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferCompletion { + pub owner_request_id: u64, + pub transfer_id: u64, + pub total_bytes: u64, + pub sha256: String, +} + +pub struct CompletedInboundTransfer { + pub declaration: TransferDeclaration, + pub file: tokio::fs::File, +} + +pub struct InboundTransfer { + declaration: TransferDeclaration, + received_bytes: u64, + hasher: Sha256, + file: tokio::fs::File, +} + +#[derive(Debug)] +pub enum InboundTransferError { + InvalidDeclaration, + InvalidChunk, + InvalidCompletion, + Io(std::io::Error), +} + +impl fmt::Display for InboundTransferError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidDeclaration => formatter.write_str("invalid inbound transfer declaration"), + Self::InvalidChunk => formatter.write_str("invalid inbound transfer chunk"), + Self::InvalidCompletion => formatter.write_str("invalid inbound transfer completion"), + Self::Io(error) => write!(formatter, "inbound transfer I/O failed: {error}"), + } + } +} + +impl std::error::Error for InboundTransferError {} + +impl From for InboundTransferError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl InboundTransfer { + pub fn begin( + declaration: TransferDeclaration, + transfer_limit: u64, + file: tokio::fs::File, + ) -> Result { + if declaration.owner_request_id == 0 + || declaration.transfer_id == 0 + || declaration.total_bytes > transfer_limit + || !valid_sha256(&declaration.sha256) + { + return Err(InboundTransferError::InvalidDeclaration); + } + Ok(Self { + declaration, + received_bytes: 0, + hasher: Sha256::new(), + file, + }) + } + + pub async fn write_chunk( + &mut self, + transfer_id: u64, + bytes: &[u8], + ) -> Result<(), InboundTransferError> { + if transfer_id != self.declaration.transfer_id + || bytes.is_empty() + || bytes.len() > MAX_PAYLOAD_CHUNK_BYTES + || self.received_bytes.saturating_add(bytes.len() as u64) > self.declaration.total_bytes + { + return Err(InboundTransferError::InvalidChunk); + } + self.file.write_all(bytes).await?; + self.hasher.update(bytes); + self.received_bytes += bytes.len() as u64; + Ok(()) + } + + pub async fn complete( + mut self, + completion: TransferCompletion, + ) -> Result { + let actual_sha256 = format!("sha256:{:x}", self.hasher.finalize()); + if completion.owner_request_id != self.declaration.owner_request_id + || completion.transfer_id != self.declaration.transfer_id + || completion.total_bytes != self.declaration.total_bytes + || completion.sha256 != self.declaration.sha256 + || self.received_bytes != self.declaration.total_bytes + || actual_sha256 != self.declaration.sha256 + { + return Err(InboundTransferError::InvalidCompletion); + } + self.file.flush().await?; + Ok(CompletedInboundTransfer { + declaration: self.declaration, + file: self.file, + }) + } +} + +fn valid_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/lib.rs b/src-tauri/crates/wsl-environment-worker/src/lib.rs new file mode 100644 index 00000000..6a0af937 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/lib.rs @@ -0,0 +1,857 @@ +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +#[cfg(target_os = "linux")] +use environment_engine::inspection::{ + self as engine_inspection, EntryKind as EngineEntryKind, ErrorCode as EngineErrorCode, + InspectionError as EngineInspectionError, +}; +#[cfg(target_os = "linux")] +use environment_engine::{ + directory as engine_directory, document as engine_document, entry as engine_entry, + manifest as engine_manifest, path as engine_path, projection as engine_projection, +}; +#[cfg(target_os = "linux")] +use environment_protocol::{ + DirectoryCountFact, DocumentReadFact, DocumentReadState, EntryFact, EntryFactKind, + EntryMetadata, InspectionEntryKind, InspectionErrorCode, InspectionFact, ManifestRecord, + ManifestRecordKind, PathMetadataContent, PathMetadataFact, PathMetadataKind, ProjectedTarget, + MAX_DIRECTORY_COUNT_LIMIT, MAX_DOCUMENT_BYTES, MAX_INSPECTION_CONTENT_BYTES, + MAX_INSPECTION_FACTS, MAX_INSPECTION_ROOTS, MAX_MANIFEST_RECORDS, + MAX_PATH_CONTENT_BYTES_PER_FILE, MAX_REQUEST_DEADLINE_MILLIS, +}; +use environment_protocol::{ + DirectoryCountRequest, DirectoryCountResponse, DirectoryListRequest, DirectoryListResponse, + DocumentReadRequest, DocumentReadResponse, EntryFactsRequest, EntryFactsResponse, + InspectionRequest, InspectionResponse, ManifestRequest, ManifestResponse, + MapWindowsPathsRequest, MapWindowsPathsResponse, Message, PathKind, PathMetadataRequest, + PathMetadataResponse, ProjectionRequest, ProjectionResponse, +}; +use sha2::{Digest, Sha256}; + +pub mod inbound_transfer; +pub mod library; +pub mod mutation; +pub mod payload; +pub mod source; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerIdentity { + pub distro: String, + pub user: String, + pub uid: u32, + pub home: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dispatch { + pub response: Option, + pub close: bool, +} + +pub struct WorkerRuntime { + build_id: String, + identity: WorkerIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestError { + pub code: &'static str, + pub phase: &'static str, +} + +impl WorkerRuntime { + pub fn new(build_id: String, identity: WorkerIdentity) -> Self { + Self { build_id, identity } + } + + pub fn dispatch(&self, message: Message) -> Dispatch { + match message { + Message::Handshake { build_id } if build_id == self.build_id => Dispatch { + response: Some(Message::HandshakeResult { + build_id: self.build_id.clone(), + distro: self.identity.distro.clone(), + user: self.identity.user.clone(), + uid: self.identity.uid, + home: self.identity.home.clone(), + }), + close: false, + }, + Message::Handshake { .. } => Dispatch { + response: Some(error("buildMismatch", "handshake")), + close: true, + }, + Message::ObservePath { path } => match execute_path_observation(&path) { + Ok(kind) => Dispatch { + response: Some(Message::PathObserved { kind }), + close: false, + }, + Err(error) => Dispatch { + response: Some(error_message(error)), + close: false, + }, + }, + Message::Shutdown => Dispatch { + response: None, + close: true, + }, + _ => Dispatch { + response: Some(error("unexpectedMessage", "request")), + close: true, + }, + } + } +} + +pub fn file_sha256(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +pub fn execute_path_observation(path: &str) -> Result { + let path = Path::new(path); + if !path.is_absolute() { + return Err(RequestError { + code: "invalidPath", + phase: "request", + }); + } + Ok(observe_path(path)) +} + +pub fn error_message(request_error: RequestError) -> Message { + error(request_error.code, request_error.phase) +} + +#[cfg(target_os = "linux")] +pub fn execute_inspection( + request: InspectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_inspection_request(&request)?; + let snapshot = engine_inspection::inspect_with_cancel( + &engine_inspection::InspectionRequest { + roots: request + .roots + .into_iter() + .map(|root| engine_inspection::InspectionRoot { + path: root.path.into(), + stat_only: root.stat_only, + }) + .collect(), + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + EngineInspectionError::Cancelled => "cancelled", + EngineInspectionError::InvalidRequest => "invalidRequest", + EngineInspectionError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "inspection", + })?; + if snapshot.facts.len() > MAX_INSPECTION_FACTS { + return Err(RequestError { + code: "resultTooLarge", + phase: "inspection", + }); + } + + Ok(InspectionResponse { + facts: snapshot + .facts + .into_iter() + .map(|fact| InspectionFact { + root_index: fact.root_index, + relative_path: fact.relative_path.as_os_str().as_bytes().to_vec(), + kind: match fact.kind { + EngineEntryKind::Missing => InspectionEntryKind::Missing, + EngineEntryKind::File => InspectionEntryKind::File, + EngineEntryKind::Directory => InspectionEntryKind::Directory, + EngineEntryKind::Symlink => InspectionEntryKind::Symlink, + EngineEntryKind::Other => InspectionEntryKind::Other, + }, + resolved_target: fact + .resolved_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + content_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + EngineErrorCode::PathUnavailable => InspectionErrorCode::PathUnavailable, + EngineErrorCode::ReadFailed => InspectionErrorCode::ReadFailed, + EngineErrorCode::ReadLinkFailed => InspectionErrorCode::ReadLinkFailed, + }), + }) + .collect(), + total_content_bytes: snapshot.total_content_bytes, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_path_metadata( + request: PathMetadataRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.queries.len() > MAX_INSPECTION_ROOTS + || request + .queries + .iter() + .any(|query| !Path::new(&query.path).is_absolute()) + || request.queries.iter().any(|query| { + query + .content_limit + .is_some_and(|limit| limit == 0 || limit > MAX_PATH_CONTENT_BYTES_PER_FILE) + }) + || request.aggregate_content_limit == 0 + || request.aggregate_content_limit > MAX_INSPECTION_CONTENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMetadata", + }); + } + let response = engine_path::inspect_paths_with_cancel( + &engine_path::PathRequest { + queries: request + .queries + .into_iter() + .map(|query| engine_path::PathQuery { + path: query.path.into(), + content_limit: query.content_limit, + }) + .collect(), + aggregate_content_limit: request.aggregate_content_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_path::PathError::Cancelled => "cancelled", + engine_path::PathError::InvalidRequest => "invalidRequest", + engine_path::PathError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "pathMetadata", + })?; + Ok(PathMetadataResponse { + facts: response + .facts + .into_iter() + .map(|fact| PathMetadataFact { + path: fact.path.to_string_lossy().into_owned(), + kind: match fact.kind { + engine_path::PathKind::Missing => PathMetadataKind::Missing, + engine_path::PathKind::Directory => PathMetadataKind::Directory, + engine_path::PathKind::SymlinkDirectory => PathMetadataKind::SymlinkDirectory, + engine_path::PathKind::SymlinkOther => PathMetadataKind::SymlinkOther, + engine_path::PathKind::Other => PathMetadataKind::Other, + engine_path::PathKind::BrokenLink => PathMetadataKind::BrokenLink, + engine_path::PathKind::Inaccessible => PathMetadataKind::Inaccessible, + }, + content: match fact.content { + engine_path::ContentState::NotRequested => PathMetadataContent::NotRequested, + engine_path::ContentState::Empty => PathMetadataContent::Empty, + engine_path::ContentState::Unreadable => PathMetadataContent::Unreadable, + engine_path::ContentState::Bytes(bytes) => PathMetadataContent::Bytes(bytes), + }, + content_truncated: fact.content_truncated, + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_path_metadata( + _request: PathMetadataRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "pathMetadata", + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_directory_count( + request: DirectoryCountRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() + || request.paths.len() > MAX_INSPECTION_ROOTS + || request + .paths + .iter() + .any(|path| !Path::new(path).is_absolute()) + || request.limit == 0 + || request.limit > MAX_DIRECTORY_COUNT_LIMIT + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "directoryCount", + }); + } + let response = engine_directory::count_entries_with_cancel( + &engine_directory::DirectoryCountRequest { + paths: request.paths.into_iter().map(Into::into).collect(), + limit: request.limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_directory::DirectoryCountError::Cancelled => "cancelled", + engine_directory::DirectoryCountError::InvalidRequest => "invalidRequest", + engine_directory::DirectoryCountError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "directoryCount", + })?; + Ok(DirectoryCountResponse { + facts: response + .facts + .into_iter() + .map(|fact| DirectoryCountFact { + path: fact.path.to_string_lossy().into_owned(), + observed_count: fact.observed_count, + truncated: fact.truncated, + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_document_read( + request: DocumentReadRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.queries.is_empty() + || request.queries.len() > MAX_INSPECTION_ROOTS + || request.queries.iter().any(|query| { + !Path::new(&query.path).is_absolute() + || query.limit == 0 + || query.limit > MAX_DOCUMENT_BYTES + }) + || request.aggregate_limit == 0 + || request.aggregate_limit > MAX_DOCUMENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "documentRead", + }); + } + let response = engine_document::read_documents_with_cancel( + &engine_document::DocumentRequest { + queries: request + .queries + .into_iter() + .map(|query| engine_document::DocumentQuery { + path: query.path.into(), + limit: query.limit, + }) + .collect(), + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_document::DocumentError::Cancelled => "cancelled", + engine_document::DocumentError::InvalidRequest => "invalidRequest", + engine_document::DocumentError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "documentRead", + })?; + Ok(DocumentReadResponse { + facts: response + .facts + .into_iter() + .map(|fact| DocumentReadFact { + path: fact.path.to_string_lossy().into_owned(), + state: match fact.state { + engine_document::DocumentState::Missing => DocumentReadState::Missing, + engine_document::DocumentState::NotFile => DocumentReadState::NotFile, + engine_document::DocumentState::Unreadable => DocumentReadState::Unreadable, + engine_document::DocumentState::Bytes(bytes) => DocumentReadState::Bytes(bytes), + }, + truncated: fact.truncated, + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_directory_list( + request: DirectoryListRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + if !Path::new(&request.path).is_absolute() + || request.limit == 0 + || request.limit > MAX_DIRECTORY_COUNT_LIMIT + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "directoryList", + }); + } + let response = engine_directory::list_child_directories_with_cancel( + &engine_directory::DirectoryListRequest { + path: request.path.into(), + limit: request.limit, + }, + is_cancelled, + ) + .map_err(|error| RequestError { + code: match error { + engine_directory::DirectoryCountError::Cancelled => "cancelled", + engine_directory::DirectoryCountError::InvalidRequest => "invalidRequest", + engine_directory::DirectoryCountError::UnsupportedPlatform => "unsupportedPlatform", + }, + phase: "directoryList", + })?; + Ok(DirectoryListResponse { + names: response + .names + .into_iter() + .map(|name| name.as_os_str().as_bytes().to_vec()) + .collect(), + truncated: response.truncated, + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_map_windows_paths( + request: MapWindowsPathsRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + if request.paths.is_empty() + || request.paths.len() > MAX_INSPECTION_ROOTS + || request + .paths + .iter() + .any(|path| !Path::new(path).is_absolute()) + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMapping", + }); + } + let mut mapped = Vec::with_capacity(request.paths.len()); + for path in request.paths { + if is_cancelled() { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + mapped.push(map_path_to_windows(Path::new(&path))); + } + Ok(MapWindowsPathsResponse { mapped }) +} + +#[cfg(target_os = "linux")] +pub fn execute_entry_facts( + request: EntryFactsRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_paths(&request.paths, request.deadline_millis, "entryFacts")?; + let response = engine_entry::inspect_entries_with_cancel( + &engine_entry::EntryRequest { + paths: request.paths.into_iter().map(Into::into).collect(), + }, + is_cancelled, + ) + .map_err(|error| planning_error(entry_error_code(error), "entryFacts"))?; + Ok(EntryFactsResponse { + facts: response + .facts + .into_iter() + .map(|fact| EntryFact { + kind: match fact.kind { + engine_entry::EntryKind::Missing => EntryFactKind::Missing, + engine_entry::EntryKind::File => EntryFactKind::File, + engine_entry::EntryKind::Directory => EntryFactKind::Directory, + engine_entry::EntryKind::Symlink => EntryFactKind::Symlink, + engine_entry::EntryKind::BrokenLink => EntryFactKind::BrokenLink, + engine_entry::EntryKind::Other => EntryFactKind::Other, + }, + metadata: fact.metadata.map(|metadata| EntryMetadata { + device: metadata.device, + inode: metadata.inode, + mode: metadata.mode, + size: metadata.size, + mtime_seconds: metadata.mtime_seconds, + mtime_nanos: metadata.mtime_nanos, + }), + link_target: fact + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +pub fn execute_projection( + request: ProjectionRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + validate_paths(&request.destinations, request.deadline_millis, "projection")?; + let response = engine_projection::project_targets_with_cancel( + &engine_projection::ProjectionRequest { + destinations: request.destinations.into_iter().map(Into::into).collect(), + }, + &is_cancelled, + ) + .map_err(|error| planning_error(projection_error_code(error), "projection"))?; + let mut targets = Vec::with_capacity(response.targets.len()); + for target in response.targets { + if is_cancelled() { + return Err(planning_error("cancelled", "projection")); + } + let storage_projection = map_path_to_windows(&target.physical_anchor) + .ok_or_else(|| planning_error("pathMappingFailed", "projection"))?; + targets.push(ProjectedTarget { + anchor_device: target.anchor_device, + anchor_inode: target.anchor_inode, + physical_destination: target.physical_destination.as_os_str().as_bytes().to_vec(), + relative_components: target + .relative_components + .into_iter() + .map(|component| component.as_os_str().as_bytes().to_vec()) + .collect(), + storage_projection, + }); + } + Ok(ProjectionResponse { targets }) +} + +#[cfg(target_os = "linux")] +pub fn execute_manifest( + request: ManifestRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::os::unix::ffi::OsStrExt; + + validate_paths( + std::slice::from_ref(&request.root), + request.deadline_millis, + "manifest", + )?; + let response = engine_manifest::build_manifest_with_cancel( + &engine_manifest::ManifestRequest { + root: request.root.into(), + }, + is_cancelled, + ) + .map_err(|error| planning_error(manifest_error_code(error), "manifest"))?; + if response.records.len() > MAX_MANIFEST_RECORDS { + return Err(planning_error("resultTooLarge", "manifest")); + } + Ok(ManifestResponse { + records: response + .records + .into_iter() + .map(|record| ManifestRecord { + relative_path: record.relative_path.as_os_str().as_bytes().to_vec(), + kind: match record.kind { + engine_manifest::ManifestKind::Directory => ManifestRecordKind::Directory, + engine_manifest::ManifestKind::File => ManifestRecordKind::File, + engine_manifest::ManifestKind::Symlink => ManifestRecordKind::Symlink, + }, + digest: record.digest, + executable: record.executable, + symlink_target: record + .symlink_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) +} + +#[cfg(target_os = "linux")] +fn validate_paths( + paths: &[String], + deadline: u64, + phase: &'static str, +) -> Result<(), RequestError> { + if paths.is_empty() + || paths.len() > MAX_INSPECTION_ROOTS + || paths.iter().any(|path| !Path::new(path).is_absolute()) + || deadline == 0 + || deadline > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(planning_error("invalidRequest", phase)); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn entry_error_code(error: engine_entry::EntryError) -> &'static str { + match error { + engine_entry::EntryError::UnsupportedPlatform => "unsupportedPlatform", + engine_entry::EntryError::InvalidRequest => "invalidRequest", + engine_entry::EntryError::Unavailable => "pathUnavailable", + engine_entry::EntryError::Cancelled => "cancelled", + } +} + +#[cfg(target_os = "linux")] +fn projection_error_code(error: engine_projection::ProjectionError) -> &'static str { + match error { + engine_projection::ProjectionError::UnsupportedPlatform => "unsupportedPlatform", + engine_projection::ProjectionError::InvalidRequest => "invalidRequest", + engine_projection::ProjectionError::Unavailable => "pathUnavailable", + engine_projection::ProjectionError::Cancelled => "cancelled", + } +} + +#[cfg(target_os = "linux")] +fn manifest_error_code(error: engine_manifest::ManifestError) -> &'static str { + match error { + engine_manifest::ManifestError::UnsupportedPlatform => "unsupportedPlatform", + engine_manifest::ManifestError::InvalidRequest => "invalidRequest", + engine_manifest::ManifestError::Unavailable => "pathUnavailable", + engine_manifest::ManifestError::UnsupportedEntry => "unsupportedEntry", + engine_manifest::ManifestError::Cancelled => "cancelled", + } +} + +fn planning_error(code: &'static str, phase: &'static str) -> RequestError { + RequestError { code, phase } +} + +#[cfg(target_os = "linux")] +#[allow( + clippy::disallowed_methods, + reason = "Environment Worker 是独立 crate,必须直接构造受控的 wslpath 子进程" +)] +fn map_path_to_windows(path: &Path) -> Option { + use std::process::{Command, Stdio}; + + let output = Command::new("wslpath") + .arg("-w") + .arg("--") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() || output.stdout.len() > 16 * 1024 { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|value| value.trim_end_matches(['\r', '\n']).to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_entry_facts( + _request: EntryFactsRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "entryFacts")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_projection( + _request: ProjectionRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "projection")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_manifest( + _request: ManifestRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "manifest")) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_map_windows_paths( + _request: MapWindowsPathsRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "pathMapping", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_directory_list( + _request: DirectoryListRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "directoryList", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_document_read( + _request: DocumentReadRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "documentRead", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_directory_count( + _request: DirectoryCountRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "directoryCount", + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn execute_inspection( + _request: InspectionRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(RequestError { + code: "unsupportedPlatform", + phase: "inspection", + }) +} + +#[cfg(target_os = "linux")] +fn validate_inspection_request(request: &InspectionRequest) -> Result<(), RequestError> { + if request.roots.is_empty() + || request.roots.len() > MAX_INSPECTION_ROOTS + || request + .roots + .iter() + .any(|root| !Path::new(&root.path).is_absolute()) + || request.per_file_limit == 0 + || request.aggregate_limit == 0 + || request.per_file_limit > request.aggregate_limit + || request.aggregate_limit > MAX_INSPECTION_CONTENT_BYTES + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "inspection", + }); + } + Ok(()) +} + +fn observe_path(path: &Path) -> PathKind { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => match std::fs::metadata(path) { + Ok(target) if target.is_dir() => PathKind::SymlinkDirectory, + Ok(_) => PathKind::SymlinkOther, + Err(error) if error.kind() == io::ErrorKind::NotFound => PathKind::BrokenLink, + Err(_) => PathKind::Inaccessible, + }, + Ok(metadata) if metadata.is_dir() => PathKind::Directory, + Ok(metadata) if metadata.is_file() => PathKind::File, + Ok(_) => PathKind::Other, + Err(error) if error.kind() == io::ErrorKind::NotFound => PathKind::Missing, + Err(_) => PathKind::Inaccessible, + } +} + +fn error(code: &str, phase: &str) -> Message { + Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + } +} diff --git a/src-tauri/crates/wsl-environment-worker/src/library.rs b/src-tauri/crates/wsl-environment-worker/src/library.rs new file mode 100644 index 00000000..5d60c8df --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/library.rs @@ -0,0 +1,181 @@ +use std::fmt; +use std::path::PathBuf; + +use environment_engine::library::{ + self as engine, CatalogWrite, ContentAction, LibraryCommit, TargetExpectation, +}; +use environment_engine::linux_mutation::ParentIdentity; +use environment_protocol::{ + LibraryCatalogResponse, LibraryMemberAction, LibraryOperationAction, LibraryOperationRequest, + MAX_REQUEST_DEADLINE_MILLIS, +}; + +use crate::payload::{PayloadError, PayloadManager}; + +pub struct LibraryManager { + root: PathBuf, +} + +#[derive(Debug)] +pub enum LibraryError { + InvalidRequest, + StaleTarget, + StalePayload, + RecoveryIncomplete, + Io, +} + +impl fmt::Display for LibraryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for LibraryError {} + +impl LibraryManager { + pub fn new(home: PathBuf) -> Self { + Self { + root: home.join(".skill-deck/skill-libraries"), + } + } + + pub fn read_catalog(&self) -> Result { + let snapshot = engine::read_catalog(&self.root).map_err(map_engine_error)?; + Ok(LibraryCatalogResponse { + present: snapshot.bytes.is_some(), + bytes: snapshot.bytes.unwrap_or_default(), + revision: snapshot.revision, + }) + } + + pub fn execute( + &self, + request: LibraryOperationRequest, + payloads: &PayloadManager, + ) -> Result { + if request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + || !valid_component(&request.operation_id) + || request.catalog_bytes.is_empty() + { + return Err(LibraryError::InvalidRequest); + } + let catalog = CatalogWrite { + expected_revision: request.expected_catalog_revision, + bytes: request.catalog_bytes, + }; + match request.action { + LibraryOperationAction::SaveCatalog { library_ids } => { + engine::write_catalog(&self.root, &library_ids, catalog).map_err(map_engine_error) + } + LibraryOperationAction::CommitMember { + library_id, + skill_name, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + mutation, + } => { + if !valid_component(&library_id) || !valid_component(&skill_name) { + return Err(LibraryError::InvalidRequest); + } + let content = match mutation { + LibraryMemberAction::Upsert { payload_id } => ContentAction::Upsert { + payload_root: payloads + .payload_root(payload_id) + .map_err(map_payload_error)?, + }, + LibraryMemberAction::Delete => ContentAction::Delete, + }; + engine::commit(LibraryCommit { + root: self.root.clone(), + operation_id: request.operation_id, + destination: self + .root + .join("libraries") + .join(library_id) + .join("skills") + .join(skill_name), + expected_target: expectation( + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + ), + content, + catalog, + }) + .map_err(map_engine_error)?; + Ok(catalog_revision(&self.root)?) + } + LibraryOperationAction::DeleteLibrary { + library_id, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + } => { + if !valid_component(&library_id) { + return Err(LibraryError::InvalidRequest); + } + engine::commit(LibraryCommit { + root: self.root.clone(), + operation_id: request.operation_id, + destination: self.root.join("libraries").join(library_id), + expected_target: expectation( + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + ), + content: ContentAction::DeleteIfPresent, + catalog, + }) + .map_err(map_engine_error)?; + Ok(catalog_revision(&self.root)?) + } + } + } +} + +fn expectation( + device: u64, + inode: u64, + fingerprint: String, + content_hash: Option, +) -> TargetExpectation { + TargetExpectation { + parent: ParentIdentity { device, inode }, + fingerprint, + content_hash, + } +} + +fn catalog_revision(root: &std::path::Path) -> Result { + engine::read_catalog(root) + .map_err(map_engine_error)? + .revision + .ok_or(LibraryError::Io) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + +fn map_engine_error(error: engine::LibraryError) -> LibraryError { + match error { + engine::LibraryError::InvalidRequest | engine::LibraryError::UnsupportedPlatform => { + LibraryError::InvalidRequest + } + engine::LibraryError::StaleTarget => LibraryError::StaleTarget, + engine::LibraryError::InvalidPayload => LibraryError::StalePayload, + engine::LibraryError::RecoveryIncomplete => LibraryError::RecoveryIncomplete, + engine::LibraryError::Io(_) => LibraryError::Io, + } +} + +fn map_payload_error(_error: PayloadError) -> LibraryError { + LibraryError::StalePayload +} diff --git a/src-tauri/crates/wsl-environment-worker/src/main.rs b/src-tauri/crates/wsl-environment-worker/src/main.rs new file mode 100644 index 00000000..4f2db1f2 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/main.rs @@ -0,0 +1,2212 @@ +use std::collections::hash_map::Entry; +use std::collections::{HashMap, VecDeque}; +use std::error::Error; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use environment_protocol::{ + codec, decode, decode_payload, encode_inspection_response, encode_payload, spawn_writer, + Envelope, MapHostPathsRequest, MapHostPathsResponse, Message, ProtocolWriter, WireRecord, + MAX_CONCURRENT_READ_REQUESTS, MAX_DOCUMENT_BYTES, MAX_INSPECTION_ROOTS, + MAX_MUTATION_TRANSFER_BYTES, MAX_PAYLOAD_TRANSFER_BYTES, MAX_PENDING_READ_REQUESTS, + MAX_REQUEST_DEADLINE_MILLIS, +}; +use futures_util::StreamExt; +use sha2::Digest; +use tokio::task::JoinSet; +use tokio_util::codec::FramedRead; +use wsl_environment_worker::inbound_transfer::{ + InboundTransfer, TransferCompletion, TransferDeclaration, +}; +use wsl_environment_worker::library::{LibraryError, LibraryManager}; +use wsl_environment_worker::mutation::MutationManager; +use wsl_environment_worker::payload::{PayloadError, PayloadManager, PreparedPayloadFile}; +use wsl_environment_worker::source::{ + probe_git, scan_source, GitSourceOptions, SourceError, SourceManager, +}; +use wsl_environment_worker::{ + error_message, execute_directory_count, execute_directory_list, execute_document_read, + execute_entry_facts, execute_inspection, execute_manifest, execute_map_windows_paths, + execute_path_metadata, execute_path_observation, execute_projection, file_sha256, RequestError, + WorkerIdentity, WorkerRuntime, +}; + +struct QueuedRequest { + request_id: u64, + message: Message, + cancelled: Arc, +} + +enum InboundAction { + Blob { + upload_id: u64, + blob_id: String, + }, + Manifest { + upload_id: u64, + }, + Mutation { + resource_id: String, + }, + Document { + request: environment_protocol::DocumentWritePreparation, + started: Instant, + }, + Library { + deadline_millis: u64, + started: Instant, + }, +} + +struct PreparedInbound { + transfer_id: u64, + total_bytes: u64, + sha256: String, + transfer_limit: u64, + path: PathBuf, + file: std::fs::File, + action: InboundAction, +} + +struct ActiveInbound { + owner_request_id: u64, + path: PathBuf, + action: InboundAction, + transfer: InboundTransfer, +} + +struct LibraryExecution { + deadline_millis: u64, + started: Instant, + path: PathBuf, +} + +const MAX_MANIFEST_TRANSFER_BYTES: u64 = 8 * 1024 * 1024; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let executable = std::env::current_exe()?; + let home = std::env::var("HOME").unwrap_or_default(); + let runtime = WorkerRuntime::new( + file_sha256(&executable)?, + WorkerIdentity { + distro: std::env::var("WSL_DISTRO_NAME").unwrap_or_default(), + user: std::env::var("USER").unwrap_or_default(), + uid: effective_user_id(), + home: home.clone(), + }, + ); + let mut reader = FramedRead::new(tokio::io::stdin(), codec()); + let (writer, writer_task) = spawn_writer(tokio::io::stdout()); + let mut queue = VecDeque::new(); + let mut active = HashMap::>::new(); + let mut tasks = JoinSet::new(); + let sources = Arc::new(tokio::sync::Mutex::new(SourceManager::new(PathBuf::from( + "/tmp", + ))?)); + let payloads = Arc::new(tokio::sync::Mutex::new(PayloadManager::new( + PathBuf::from("/tmp"), + )?)); + let mutations = Arc::new(tokio::sync::Mutex::new(MutationManager::new( + PathBuf::from("/tmp"), + )?)); + let libraries = Arc::new(tokio::sync::Mutex::new(LibraryManager::new(PathBuf::from( + home, + )))); + let documents = Arc::new(tokio::sync::Mutex::new(())); + let mut shutting_down = false; + let mut prepared_inbound: Option = None; + let mut active_inbound: Option = None; + let mut next_inbound_transfer_id = 1_u64; + + loop { + while tasks.len() < MAX_CONCURRENT_READ_REQUESTS { + let Some(request) = queue.pop_front() else { + break; + }; + let task_writer = writer.clone(); + tasks.spawn(execute_business_request( + request, + task_writer, + Arc::clone(&sources), + Arc::clone(&payloads), + Arc::clone(&mutations), + Arc::clone(&libraries), + )); + } + + if shutting_down && tasks.is_empty() { + break; + } + + tokio::select! { + completed = tasks.join_next(), if !tasks.is_empty() => { + match completed { + Some(Ok(Ok(request_id))) => { + active.remove(&request_id); + } + Some(Ok(Err(error))) => return Err(error.into()), + Some(Err(error)) => return Err(error.into()), + None => {} + } + } + frame = reader.next(), if !shutting_down => { + let Some(frame) = frame else { + cancel_all(&active); + queue.clear(); + discard_inbound(&mut prepared_inbound, &mut active_inbound).await; + shutting_down = true; + continue; + }; + let record = decode(&frame?)?; + if let WireRecord::PayloadChunk { transfer_id, bytes } = record { + let inbound = active_inbound + .as_mut() + .ok_or("worker received a payload without an owning request")?; + inbound.transfer.write_chunk(transfer_id, &bytes).await?; + continue; + } + let WireRecord::Control(envelope) = record else { unreachable!() }; + let request_id = envelope.request_id; + match envelope.message { + message @ (Message::ObservePath { .. } + | Message::InspectFilesystem { .. } + | Message::InspectPaths { .. } + | Message::CountDirectoryEntries { .. } + | Message::ReadDocuments { .. } + | Message::ReadLibraryCatalog { .. } + | Message::ListChildDirectories { .. } + | Message::MapPathsToWindows { .. } + | Message::MapHostPaths { .. } + | Message::InspectEntries { .. } + | Message::ProjectTargets { .. } + | Message::BuildManifest { .. } + | Message::AcquireGitSource { .. } + | Message::OpenLocalSource { .. } + | Message::ReleaseSource { .. } + | Message::ScanSource { .. } + | Message::SourceFingerprint { .. } + | Message::SourceRevision { .. } + | Message::ProbeGit { .. } + | Message::AcquirePayloadFromSource { .. } + | Message::VerifyPayload { .. } + | Message::ReadPayloadBlob { .. } + | Message::RemovePayload { .. } + | Message::RemovePayloadSession { .. } + | Message::SweepPayloadOrphans { .. } + | Message::BeginPayloadUpload { .. } + | Message::AcknowledgeMutationUnit { .. } + | Message::ListMutationRecovery + | Message::CleanupMutationRecovery { .. }) => { + match active.entry(request_id) { + Entry::Occupied(_) => { + send_error(&writer, request_id, "duplicateRequest", "request").await?; + } + Entry::Vacant(_) if queue.len() >= MAX_PENDING_READ_REQUESTS => { + send_error(&writer, request_id, "workerBusy", "admission").await?; + } + Entry::Vacant(entry) => { + let cancelled = Arc::new(AtomicBool::new(false)); + entry.insert(Arc::clone(&cancelled)); + queue.push_back(QueuedRequest { + request_id, + message, + cancelled, + }); + } + } + } + Message::PrepareDocumentWrite { request: preparation } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if !preparation.path.starts_with('/') + || preparation.path.ends_with('/') + || preparation.total_bytes == 0 + || preparation.total_bytes > MAX_DOCUMENT_BYTES as u64 + || !valid_transfer_sha256(&preparation.sha256) + || preparation + .expected_revision + .as_deref() + .is_some_and(|revision| !valid_transfer_sha256(revision)) + || preparation.deadline_millis == 0 + || preparation.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "documentWrite").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-document-request-{}-{}", + std::process::id(), + request_id + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "documentWrite").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error(&writer, request_id, "workerIoFailed", "documentWrite").await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + transfer_id, + total_bytes: preparation.total_bytes, + sha256: preparation.sha256.clone(), + transfer_limit: MAX_DOCUMENT_BYTES as u64, + path, + file, + action: InboundAction::Document { + request: preparation, + started: Instant::now(), + }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::RemoveDocument { request } => { + if !request.path.starts_with('/') + || request.path.ends_with('/') + || request + .expected_revision + .as_deref() + .is_some_and(|revision| !valid_transfer_sha256(revision)) + || request.deadline_millis == 0 + || request.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "documentRemove").await?; + continue; + } + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + send_error(&writer, request_id, "duplicateRequest", "documentRemove").await?; + continue; + } + tasks.spawn(execute_document_remove( + request_id, + request, + Instant::now(), + writer.clone(), + Arc::clone(&documents), + cancelled, + )); + } + Message::PrepareLibraryOperation { request: preparation } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if preparation.total_bytes == 0 + || preparation.total_bytes > MAX_MUTATION_TRANSFER_BYTES as u64 + || !valid_transfer_sha256(&preparation.sha256) + || preparation.deadline_millis == 0 + || preparation.deadline_millis + > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + send_error(&writer, request_id, "invalidRequest", "library").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-library-request-{}-{}", + std::process::id(), + request_id + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "library").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error(&writer, request_id, "workerIoFailed", "library").await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + transfer_id, + total_bytes: preparation.total_bytes, + sha256: preparation.sha256, + transfer_limit: MAX_MUTATION_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Library { + deadline_millis: preparation.deadline_millis, + started: Instant::now(), + }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::UploadPayloadBlob { + upload_id, + blob_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes > MAX_PAYLOAD_TRANSFER_BYTES as u64 + || sha256 != format!("sha256:{blob_id}") + { + send_error(&writer, request_id, "invalidTransfer", "payloadUpload").await?; + continue; + } + match payloads.lock().await.prepare_blob(upload_id, &blob_id) { + Ok(PreparedPayloadFile { path, file }) => { + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_PAYLOAD_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Blob { upload_id, blob_id }, + }); + writer.send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })).await?; + } + Err(error) => { + send_payload_error( + &writer, + request_id, + SourcePayloadError::Payload(error), + ).await?; + } + } + } + Message::FinalizePayloadUpload { + upload_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes == 0 || total_bytes > MAX_MANIFEST_TRANSFER_BYTES { + send_error(&writer, request_id, "invalidTransfer", "payloadUpload").await?; + continue; + } + match payloads.lock().await.prepare_manifest(upload_id) { + Ok(PreparedPayloadFile { path, file }) => { + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_MANIFEST_TRANSFER_BYTES, + path, + file, + action: InboundAction::Manifest { upload_id }, + }); + writer.send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })).await?; + } + Err(error) => { + send_payload_error( + &writer, + request_id, + SourcePayloadError::Payload(error), + ).await?; + } + } + } + Message::PrepareMutationUnit { + resource_id, + total_bytes, + sha256, + } => { + if prepared_inbound.is_some() || active_inbound.is_some() { + send_error(&writer, request_id, "workerBusy", "inboundTransfer").await?; + continue; + } + if total_bytes == 0 + || total_bytes > MAX_MUTATION_TRANSFER_BYTES as u64 + || !valid_transfer_sha256(&sha256) + || resource_id.is_empty() + || !resource_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') + }) + { + send_error(&writer, request_id, "invalidTransfer", "mutation").await?; + continue; + } + let path = PathBuf::from(format!( + "/tmp/.skill-deck-mutation-request-{resource_id}-{}", + std::process::id() + )); + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => file, + Err(_) => { + send_error(&writer, request_id, "workerBusy", "mutation").await?; + continue; + } + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(0o600), + ) + .is_err() + { + drop(file); + let _ = std::fs::remove_file(&path); + send_error( + &writer, + request_id, + "workerIoFailed", + "mutation", + ) + .await?; + continue; + } + } + let transfer_id = next_inbound_transfer_id; + next_inbound_transfer_id = next_inbound_transfer_id + .checked_add(1) + .ok_or("inbound transfer handle space exhausted")?; + prepared_inbound = Some(PreparedInbound { + transfer_id, + total_bytes, + sha256, + transfer_limit: MAX_MUTATION_TRANSFER_BYTES as u64, + path, + file, + action: InboundAction::Mutation { resource_id }, + }); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::TransferReady { transfer_id }, + })) + .await?; + } + Message::BeginTransfer { + transfer_id, + total_bytes, + sha256, + owner_request_id, + } => { + let prepared = prepared_inbound + .take() + .ok_or("worker received an unprepared inbound transfer")?; + if request_id != owner_request_id + || transfer_id != prepared.transfer_id + || total_bytes != prepared.total_bytes + || sha256 != prepared.sha256 + { + return Err("inbound transfer does not match its preparation".into()); + } + let transfer = InboundTransfer::begin( + TransferDeclaration { + owner_request_id, + transfer_id, + total_bytes, + sha256, + }, + prepared.transfer_limit, + tokio::fs::File::from_std(prepared.file), + )?; + active_inbound = Some(ActiveInbound { + owner_request_id, + path: prepared.path, + action: prepared.action, + transfer, + }); + } + Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + } => { + let inbound = active_inbound + .take() + .ok_or("worker completed an inbound transfer that is not active")?; + let completed = inbound.transfer.complete(TransferCompletion { + owner_request_id: request_id, + transfer_id, + total_bytes, + sha256, + }).await?; + drop(completed.file); + if let InboundAction::Mutation { resource_id } = inbound.action { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate mutation request".into()); + } + tasks.spawn(execute_mutation_request( + request_id, + resource_id, + inbound.path, + writer.clone(), + Arc::clone(&payloads), + Arc::clone(&mutations), + cancelled, + )); + } else if let InboundAction::Document { request, started } = inbound.action { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate document request".into()); + } + tasks.spawn(execute_document_write( + request_id, + request, + started, + inbound.path, + writer.clone(), + Arc::clone(&documents), + cancelled, + )); + } else if let InboundAction::Library { + deadline_millis, + started, + } = inbound.action + { + let cancelled = Arc::new(AtomicBool::new(false)); + if active.insert(request_id, Arc::clone(&cancelled)).is_some() { + return Err("duplicate Library request".into()); + } + tasks.spawn(execute_library_operation( + request_id, + LibraryExecution { + deadline_millis, + started, + path: inbound.path, + }, + writer.clone(), + Arc::clone(&payloads), + Arc::clone(&libraries), + cancelled, + )); + } else { + complete_inbound_action( + &writer, + request_id, + Arc::clone(&payloads), + inbound.path, + inbound.action, + ).await?; + } + } + Message::Cancel { target_request_id } => { + if let Some(cancelled) = active.get(&target_request_id) { + cancelled.store(true, Ordering::Release); + } + if active_inbound + .as_ref() + .is_some_and(|inbound| inbound.owner_request_id == target_request_id) + { + let inbound = active_inbound.take().unwrap(); + let _ = tokio::fs::remove_file(inbound.path).await; + match inbound.action { + InboundAction::Blob { upload_id, .. } + | InboundAction::Manifest { upload_id } => { + payloads.lock().await.abort_upload(upload_id); + send_error(&writer, target_request_id, "cancelled", "payloadUpload").await?; + } + InboundAction::Mutation { .. } => { + send_error(&writer, target_request_id, "cancelled", "mutation").await?; + } + InboundAction::Document { .. } => { + send_error(&writer, target_request_id, "cancelled", "documentWrite").await?; + } + InboundAction::Library { .. } => { + send_error(&writer, target_request_id, "cancelled", "library").await?; + } + } + } + } + Message::Shutdown => { + cancel_all(&active); + queue.clear(); + discard_inbound(&mut prepared_inbound, &mut active_inbound).await; + shutting_down = true; + } + message => { + let dispatch = runtime.dispatch(message); + if let Some(message) = dispatch.response { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await?; + } + if dispatch.close { + cancel_all(&active); + queue.clear(); + shutting_down = true; + } + } + } + } + } + } + + drop(writer); + writer_task.await??; + Ok(()) +} + +async fn execute_business_request( + request: QueuedRequest, + writer: ProtocolWriter, + sources: Arc>, + payloads: Arc>, + mutations: Arc>, + libraries: Arc>, +) -> Result { + let request_id = request.request_id; + match request.message { + Message::ObservePath { path } => { + let result = tokio::task::spawn_blocking(move || execute_path_observation(&path)) + .await + .map_err(|error| error.to_string())?; + if !request.cancelled.load(Ordering::Acquire) { + let message = result + .map(|kind| Message::PathObserved { kind }) + .unwrap_or_else(error_message); + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string())?; + } + } + Message::InspectFilesystem { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let inspection_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_inspection(intent, || inspection_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "inspection", + }) + } + }; + let externally_cancelled = request.cancelled.load(Ordering::Acquire) + && !matches!( + result, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + ); + if externally_cancelled { + return Ok(request_id); + } + match result { + Ok(response) => { + let payload = + encode_inspection_response(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(error), + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + Message::InspectPaths { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_path_metadata(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMetadata", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::CountDirectoryEntries { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_directory_count(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "directoryCount", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ReadDocuments { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_document_read(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "documentRead", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ListChildDirectories { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_directory_list(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "directoryList", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::MapPathsToWindows { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_map_windows_paths(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMapping", + }) + } + }; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::MapHostPaths { request: intent } => { + let result = execute_map_host_paths(intent, Arc::clone(&request.cancelled)).await; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::InspectEntries { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_entry_facts(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "entryFacts").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::ProjectTargets { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_projection(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "projection").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::BuildManifest { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_manifest(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = timeout_result(deadline, task, &cancelled, "manifest").await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } + Message::AcquireGitSource { request: intent } => { + let result = sources + .lock() + .await + .acquire_git( + GitSourceOptions { + url: intent.url, + git_ref: intent.git_ref, + proxy: intent.proxy, + deadline: Duration::from_millis(intent.deadline_millis), + }, + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|source| Message::SourceOpened { + source_id: source.id, + root: source.root.to_string_lossy().into_owned(), + revision: source.revision, + }), + ) + .await?; + } + Message::OpenLocalSource { request: intent } => { + let result = sources.lock().await.open_local(&intent.path); + send_source_result( + &writer, + request_id, + result.map(|source| Message::SourceOpened { + source_id: source.id, + root: source.root.to_string_lossy().into_owned(), + revision: source.revision, + }), + ) + .await?; + } + Message::ReleaseSource { source_id } => { + let result = sources + .lock() + .await + .release(source_id) + .map(|()| Message::SourceReleased { source_id }); + send_source_result(&writer, request_id, result).await?; + } + Message::ScanSource { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let scan_cancelled = Arc::clone(&cancelled); + let source_manager = Arc::clone(&sources); + let task = tokio::task::spawn_blocking(move || { + let manager = source_manager.blocking_lock(); + scan_source(&manager, intent, || scan_cancelled.load(Ordering::Acquire)) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(SourceError::DeadlineExceeded) + } + }; + if request.cancelled.load(Ordering::Acquire) + && !matches!(result, Err(SourceError::DeadlineExceeded)) + { + return Ok(request_id); + } + match result { + Ok(response) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_source_error(&writer, request_id, error).await?, + } + } + Message::SourceFingerprint { + source_id, + relative_path, + deadline_millis, + } => { + let deadline = Duration::from_millis(deadline_millis); + let source_manager = Arc::clone(&sources); + let relative_path = posix_path(relative_path); + let task = tokio::task::spawn_blocking(move || { + source_manager + .blocking_lock() + .fingerprint(source_id, &relative_path) + }); + let result = match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => Err(SourceError::DeadlineExceeded), + }; + send_source_result( + &writer, + request_id, + result.map(|fingerprint| Message::SourceFingerprintResult { fingerprint }), + ) + .await?; + } + Message::SourceRevision { + source_id, + relative_path, + deadline_millis, + } => { + let result = sources + .lock() + .await + .tree_revision( + source_id, + &posix_path(relative_path), + Duration::from_millis(deadline_millis), + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|revision| Message::SourceRevisionResult { revision }), + ) + .await?; + } + Message::ProbeGit { request: intent } => { + let result = probe_git( + GitSourceOptions { + url: intent.url, + git_ref: None, + proxy: intent.proxy, + deadline: Duration::from_millis(intent.deadline_millis), + }, + Arc::clone(&request.cancelled), + ) + .await; + if request.cancelled.load(Ordering::Acquire) { + return Ok(request_id); + } + send_source_result( + &writer, + request_id, + result.map(|revision| Message::GitProbed { revision }), + ) + .await?; + } + Message::AcquirePayloadFromSource { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let build_cancelled = Arc::clone(&cancelled); + let source_manager = Arc::clone(&sources); + let payload_manager = Arc::clone(&payloads); + let relative_path = posix_path(intent.relative_path); + let mut task = tokio::task::spawn_blocking(move || { + let sources = source_manager.blocking_lock(); + let source_root = sources.resolve(intent.source_id, &relative_path)?; + payload_manager + .blocking_lock() + .acquire_from_source_with_cancel( + &intent.session_id, + &intent.payload_name, + &source_root, + || build_cancelled.load(Ordering::Acquire), + ) + .map_err(SourcePayloadError::Payload) + }); + let result = match tokio::time::timeout(deadline, &mut task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + cancelled.store(true, Ordering::Release); + let _ = task.await.map_err(|error| error.to_string())?; + Err(SourcePayloadError::DeadlineExceeded) + } + }; + if request.cancelled.load(Ordering::Acquire) + && !matches!(result, Err(SourcePayloadError::DeadlineExceeded)) + { + return Ok(request_id); + } + match result { + Ok(payload) => { + let encoded = encode_payload(&payload.into_response()) + .map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_payload_error(&writer, request_id, error).await?, + } + } + Message::VerifyPayload { request: intent } => { + let payload_manager = Arc::clone(&payloads); + let task = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .verify(&intent.session_id, &intent.payload_name) + }); + let result = + match tokio::time::timeout(Duration::from_millis(intent.deadline_millis), task) + .await + { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => Err(PayloadError::StalePayload), + }; + match result { + Ok(payload) => { + let encoded = encode_payload(&payload.map(|payload| payload.into_response())) + .map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(&writer, request_id, SourcePayloadError::Payload(error)) + .await? + } + } + } + Message::ReadPayloadBlob { + payload_id, + blob_id, + deadline_millis: _, + } => { + let file = payloads + .lock() + .await + .read_blob(payload_id, &blob_id) + .map_err(SourcePayloadError::Payload); + match file { + Ok(Some(file)) => { + let total_bytes = file.metadata().map_err(|error| error.to_string())?.len(); + writer + .send_reader_transfer_with_limit( + request_id, + request_id, + tokio::fs::File::from_std(file), + total_bytes, + format!("sha256:{blob_id}"), + MAX_PAYLOAD_TRANSFER_BYTES, + ) + .await + .map_err(|error| error.to_string())?; + } + Ok(None) => { + send_payload_error(&writer, request_id, SourcePayloadError::MissingPayload) + .await? + } + Err(error) => send_payload_error(&writer, request_id, error).await?, + } + } + Message::ReadLibraryCatalog { deadline_millis } => { + let result = if deadline_millis == 0 + || deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + Err(LibraryError::InvalidRequest) + } else { + let task = + tokio::task::spawn_blocking(move || libraries.blocking_lock().read_catalog()); + match tokio::time::timeout(Duration::from_millis(deadline_millis), task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + request.cancelled.store(true, Ordering::Release); + send_error(&writer, request_id, "deadlineExceeded", "libraryRead") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + } + }; + match result { + Ok(response) if !request.cancelled.load(Ordering::Acquire) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Ok(_) => {} + Err(error) => send_library_error(&writer, request_id, error, "libraryRead").await?, + } + } + Message::RemovePayload { + session_id, + payload_name, + } => { + let result = payloads + .lock() + .await + .remove(&session_id, &payload_name) + .map(|()| Message::PayloadRemoved { + session_id, + payload_name, + }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::RemovePayloadSession { session_id } => { + let result = payloads + .lock() + .await + .remove_session(&session_id) + .map(|()| Message::PayloadSessionRemoved { session_id }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::SweepPayloadOrphans { + protected_session_ids, + } => { + let result = payloads.lock().await.sweep_orphans(&protected_session_ids); + match result { + Ok(report) => { + let encoded = encode_payload(&report).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &encoded) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(&writer, request_id, SourcePayloadError::Payload(error)) + .await? + } + } + } + Message::BeginPayloadUpload { + session_id, + payload_name, + } => { + let result = payloads + .lock() + .await + .begin_upload(&session_id, &payload_name) + .map(|upload_id| Message::PayloadUploadBegun { upload_id }) + .map_err(SourcePayloadError::Payload); + send_payload_control_result(&writer, request_id, result).await?; + } + Message::AcknowledgeMutationUnit { cleanup } => { + let manager = mutations.lock_owned().await; + let resource_id = cleanup.resource_id.clone(); + let result = tokio::task::spawn_blocking(move || manager.acknowledge(&cleanup)) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::MutationAcknowledged { resource_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: "staleRecovery".to_string(), + phase: "mutationAck".to_string(), + parameters: vec![("message".to_string(), error.to_string())], + }, + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + Message::ListMutationRecovery => { + let manager = mutations.lock_owned().await; + let result = tokio::task::spawn_blocking(move || manager.recovery_store().list()) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(records) => { + let response = environment_protocol::MutationRecoveryList { + records: records + .into_iter() + .map(|record| environment_protocol::MutationRecoveryRecord { + resource_id: record.resource_id, + managed_root: record.managed_root.to_string_lossy().into_owned(), + state: if record.unsafe_root { + environment_protocol::MutationRecoveryState::Unsafe + } else if record.marker_bytes.is_some() { + environment_protocol::MutationRecoveryState::Present + } else { + environment_protocol::MutationRecoveryState::Unreadable + }, + marker_bytes: record.marker_bytes.unwrap_or_default(), + }) + .collect(), + }; + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_error( + &writer, + request_id, + "recoveryUnavailable", + "mutationRecovery", + ) + .await + .map_err(|error| error.to_string())?; + let _ = error; + } + } + } + Message::CleanupMutationRecovery { + resource_id, + expected_marker_json, + backups, + } => { + let manager = mutations.lock_owned().await; + let result = tokio::task::spawn_blocking({ + let resource_id = resource_id.clone(); + move || { + manager.recovery_store().cleanup( + &resource_id, + &expected_marker_json, + &backups.into_iter().map(PathBuf::from).collect::>(), + ) + } + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::MutationRecoveryCleaned { resource_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: "staleRecovery".to_string(), + phase: "mutationRecovery".to_string(), + parameters: vec![("message".to_string(), error.to_string())], + }, + })) + .await + .map_err(|error| error.to_string())?; + } + } + } + _ => return Err("non-business message entered the worker queue".to_string()), + } + Ok(request_id) +} + +async fn execute_map_host_paths( + request: MapHostPathsRequest, + cancelled: Arc, +) -> Result { + if request.paths.is_empty() + || request.paths.len() > MAX_INSPECTION_ROOTS + || request + .paths + .iter() + .any(|path| path.is_empty() || path.contains('\0')) + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(RequestError { + code: "invalidRequest", + phase: "pathMapping", + }); + } + + let mapping = async { + let mut mapped = Vec::with_capacity(request.paths.len()); + for path in request.paths { + if cancelled.load(Ordering::Acquire) { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + mapped.push(map_host_path(&path, Arc::clone(&cancelled)).await?); + } + Ok(MapHostPathsResponse { mapped }) + }; + match tokio::time::timeout(Duration::from_millis(request.deadline_millis), mapping).await { + Ok(result) => result, + Err(_) => { + cancelled.store(true, Ordering::Release); + Err(RequestError { + code: "deadlineExceeded", + phase: "pathMapping", + }) + } + } +} + +#[allow( + clippy::disallowed_methods, + reason = "Environment Worker 是独立 crate,必须直接构造受控的 wslpath 子进程" +)] +async fn map_host_path(path: &str, cancelled: Arc) -> Result { + let mut command = tokio::process::Command::new("wslpath"); + command + .args(["-u", "--", path]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let output = tokio::select! { + output = command.output() => output, + _ = wait_until_cancelled(cancelled) => { + return Err(RequestError { + code: "cancelled", + phase: "pathMapping", + }); + } + } + .map_err(|_| RequestError { + code: "pathUnavailable", + phase: "pathMapping", + })?; + if !output.status.success() || output.stdout.len() > 16 * 1024 { + return Err(RequestError { + code: "pathUnavailable", + phase: "pathMapping", + }); + } + String::from_utf8(output.stdout) + .ok() + .map(|path| path.trim_end_matches(['\r', '\n']).to_string()) + .filter(|path| path.starts_with('/') && !path.contains('\0')) + .ok_or(RequestError { + code: "pathUnavailable", + phase: "pathMapping", + }) +} + +async fn wait_until_cancelled(cancelled: Arc) { + while !cancelled.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +enum SourcePayloadError { + Source(SourceError), + Payload(PayloadError), + MissingPayload, + DeadlineExceeded, +} + +async fn complete_inbound_action( + writer: &ProtocolWriter, + request_id: u64, + payloads: Arc>, + path: PathBuf, + action: InboundAction, +) -> Result<(), String> { + match action { + InboundAction::Blob { upload_id, blob_id } => { + let payload_manager = Arc::clone(&payloads); + let committed_blob_id = blob_id.clone(); + let result = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .commit_blob(upload_id, &committed_blob_id, path) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::PayloadBlobUploaded { upload_id, blob_id }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + payloads.lock().await.abort_upload(upload_id); + send_payload_error(writer, request_id, SourcePayloadError::Payload(error)) + .await?; + } + } + } + InboundAction::Manifest { upload_id } => { + let payload_manager = Arc::clone(&payloads); + let result = tokio::task::spawn_blocking(move || { + payload_manager + .blocking_lock() + .finalize_upload(upload_id, path) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(payload) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::PayloadUploadFinalized { + payload_id: payload.id, + }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + send_payload_error(writer, request_id, SourcePayloadError::Payload(error)) + .await?; + } + } + } + InboundAction::Mutation { .. } => { + return Err("mutation transfer entered payload completion".to_string()); + } + InboundAction::Document { .. } => { + return Err("document transfer entered payload completion".to_string()); + } + InboundAction::Library { .. } => { + return Err("Library transfer entered payload completion".to_string()); + } + } + Ok(()) +} + +async fn execute_mutation_request( + request_id: u64, + resource_id: String, + path: PathBuf, + writer: ProtocolWriter, + payloads: Arc>, + mutations: Arc>, + cancelled: Arc, +) -> Result { + let bytes = tokio::fs::read(&path) + .await + .map_err(|error| error.to_string())?; + let _ = tokio::fs::remove_file(path).await; + let request: environment_protocol::MutationUnitRequest = + environment_protocol::decode_payload(&bytes).map_err(|error| error.to_string())?; + if request.resource_id != resource_id { + return Err("mutation transfer resource does not match its request".to_string()); + } + if cancelled.load(Ordering::Acquire) { + send_mutation_outcome( + &writer, + request_id, + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .await?; + return Ok(request_id); + } + let started = tokio::time::Instant::now(); + let deadline = Duration::from_millis(request.deadline_millis); + let mutation_guard = tokio::select! { + guard = mutations.lock_owned() => guard, + _ = tokio::time::sleep(deadline) => { + send_mutation_outcome(&writer, request_id, &mutation_deadline_outcome()).await?; + return Ok(request_id); + } + }; + let deadline_elapsed = Arc::new(AtomicBool::new(false)); + let deadline_signal = Arc::clone(&deadline_elapsed); + let deadline_task = tokio::spawn(async move { + tokio::time::sleep(deadline.saturating_sub(started.elapsed())).await; + deadline_signal.store(true, Ordering::Release); + }); + let payload_manager = Arc::clone(&payloads); + let accept_cancelled = Arc::clone(&cancelled); + let accept_deadline = Arc::clone(&deadline_elapsed); + let accepted = tokio::task::spawn_blocking(move || { + let payloads = payload_manager.blocking_lock(); + match mutation_guard.accept(request, &payloads, || { + accept_cancelled.load(Ordering::Acquire) || accept_deadline.load(Ordering::Acquire) + }) { + Ok(accepted) => Ok((mutation_guard, accepted)), + Err(error) => Err((mutation_guard, error)), + } + }) + .await + .map_err(|error| error.to_string())?; + let (mutation_guard, accepted) = match accepted { + Ok(value) => value, + Err((_guard, error)) => { + deadline_task.abort(); + let outcome = if deadline_elapsed.load(Ordering::Acquire) { + mutation_deadline_outcome() + } else { + mutation_accept_error(error, cancelled.load(Ordering::Acquire)) + }; + send_mutation_outcome(&writer, request_id, &outcome).await?; + return Ok(request_id); + } + }; + if MutationManager::requires_acceptance(&accepted) { + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id, + message: Message::MutationAccepted { + resource_id: resource_id.clone(), + }, + })) + .await + .map_err(|error| error.to_string())?; + } + let execute_cancelled = Arc::clone(&cancelled); + let execute_deadline = Arc::clone(&deadline_elapsed); + let outcome = tokio::task::spawn_blocking(move || { + mutation_guard.execute(accepted, || { + execute_cancelled.load(Ordering::Acquire) || execute_deadline.load(Ordering::Acquire) + }) + }) + .await + .map_err(|error| error.to_string())? + .unwrap_or_else( + |error| environment_protocol::MutationUnitOutcome::RecoveryRequired { + resource_id, + message: error.to_string(), + }, + ); + deadline_task.abort(); + let outcome = if deadline_elapsed.load(Ordering::Acquire) + && matches!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ) { + mutation_deadline_outcome() + } else { + outcome + }; + send_mutation_outcome(&writer, request_id, &outcome).await?; + Ok(request_id) +} + +async fn execute_document_write( + request_id: u64, + preparation: environment_protocol::DocumentWritePreparation, + started: Instant, + path: PathBuf, + writer: ProtocolWriter, + gate: Arc>, + cancelled: Arc, +) -> Result { + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(_) => { + let _ = tokio::fs::remove_file(path).await; + send_error(&writer, request_id, "documentWriteFailed", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let _ = tokio::fs::remove_file(path).await; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let Some(remaining) = + Duration::from_millis(preparation.deadline_millis).checked_sub(started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let _guard = match tokio::time::timeout(remaining, gate.lock()).await { + Ok(guard) => guard, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let expected_digest = format!("sha256:{:x}", sha2::Sha256::digest(&bytes)); + if expected_digest != preparation.sha256 { + send_error(&writer, request_id, "invalidTransfer", "documentWrite") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + environment_engine::document::write_document_atomic( + PathBuf::from(preparation.path).as_path(), + preparation.expected_revision.as_deref(), + &bytes, + ) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(revision) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::DocumentWritten { revision }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::Conflict) => { + send_error(&writer, request_id, "documentConflict", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::InvalidTarget) => { + send_error(&writer, request_id, "invalidTarget", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + Err(error) => { + let _ = error; + send_error(&writer, request_id, "documentWriteFailed", "documentWrite") + .await + .map_err(|error| error.to_string())?; + } + } + Ok(request_id) +} + +async fn execute_library_operation( + request_id: u64, + execution: LibraryExecution, + writer: ProtocolWriter, + payloads: Arc>, + libraries: Arc>, + cancelled: Arc, +) -> Result { + let bytes = match tokio::fs::read(&execution.path).await { + Ok(bytes) => bytes, + Err(_) => { + let _ = tokio::fs::remove_file(execution.path).await; + send_error(&writer, request_id, "libraryIo", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let _ = tokio::fs::remove_file(execution.path).await; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let request = match decode_payload::(&bytes) { + Ok(request) if request.deadline_millis == execution.deadline_millis => request, + _ => { + send_error(&writer, request_id, "invalidRequest", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + let Some(remaining) = + Duration::from_millis(execution.deadline_millis).checked_sub(execution.started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let manager = match tokio::time::timeout(remaining, libraries.lock_owned()).await { + Ok(manager) => manager, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "library") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + let payloads = payloads.blocking_lock(); + manager.execute(request, &payloads) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(catalog_revision) => { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::LibraryOperationCompleted { catalog_revision }, + })) + .await + .map_err(|error| error.to_string())?; + } + Err(error) => send_library_error(&writer, request_id, error, "library").await?, + } + Ok(request_id) +} + +async fn execute_document_remove( + request_id: u64, + request: environment_protocol::DocumentRemoveRequest, + started: Instant, + writer: ProtocolWriter, + gate: Arc>, + cancelled: Arc, +) -> Result { + let Some(remaining) = + Duration::from_millis(request.deadline_millis).checked_sub(started.elapsed()) + else { + send_error(&writer, request_id, "deadlineExceeded", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + }; + let _guard = match tokio::time::timeout(remaining, gate.lock()).await { + Ok(guard) => guard, + Err(_) => { + send_error(&writer, request_id, "deadlineExceeded", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + }; + if cancelled.load(Ordering::Acquire) { + send_error(&writer, request_id, "cancelled", "documentRemove") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + let result = tokio::task::spawn_blocking(move || { + environment_engine::document::remove_document_if_revision( + PathBuf::from(request.path).as_path(), + request.expected_revision.as_deref(), + ) + }) + .await + .map_err(|error| error.to_string())?; + match result { + Ok(()) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::DocumentRemoved, + })) + .await + .map_err(|error| error.to_string())?, + Err(environment_engine::document::DocumentWriteError::Conflict) => { + send_error(&writer, request_id, "documentConflict", "documentRemove") + .await + .map_err(|error| error.to_string())? + } + Err(environment_engine::document::DocumentWriteError::InvalidTarget) => { + send_error(&writer, request_id, "invalidTarget", "documentRemove") + .await + .map_err(|error| error.to_string())? + } + Err(_) => send_error(&writer, request_id, "documentWriteFailed", "documentRemove") + .await + .map_err(|error| error.to_string())?, + } + Ok(request_id) +} + +async fn send_library_error( + writer: &ProtocolWriter, + request_id: u64, + error: LibraryError, + phase: &'static str, +) -> Result<(), String> { + let code = match error { + LibraryError::InvalidRequest => "invalidRequest", + LibraryError::StaleTarget => "staleTarget", + LibraryError::StalePayload => "stalePayload", + LibraryError::RecoveryIncomplete => "libraryRecoveryIncomplete", + LibraryError::Io => "libraryIo", + }; + send_error(writer, request_id, code, phase) + .await + .map_err(|error| error.to_string()) +} + +fn mutation_deadline_outcome() -> environment_protocol::MutationUnitOutcome { + environment_protocol::MutationUnitOutcome::Failed { + code: "deadlineExceeded".to_string(), + phase: "mutation".to_string(), + parameters: Vec::new(), + message: "WSL mutation deadline exceeded".to_string(), + } +} + +async fn send_mutation_outcome( + writer: &ProtocolWriter, + request_id: u64, + outcome: &environment_protocol::MutationUnitOutcome, +) -> Result<(), String> { + let payload = encode_payload(outcome).map_err(|error| error.to_string())?; + writer + .send_transfer_with_limit( + request_id, + request_id, + &payload, + MAX_MUTATION_TRANSFER_BYTES, + ) + .await + .map_err(|error| error.to_string()) +} + +fn valid_transfer_sha256(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) +} + +fn mutation_accept_error( + error: wsl_environment_worker::mutation::WorkerMutationError, + cancelled: bool, +) -> environment_protocol::MutationUnitOutcome { + use environment_engine::linux_mutation::MutationError as EngineError; + use wsl_environment_worker::mutation::WorkerMutationError; + + if cancelled || matches!(error, WorkerMutationError::Engine(EngineError::Cancelled)) { + return environment_protocol::MutationUnitOutcome::Cancelled; + } + let code = match error { + WorkerMutationError::Engine(EngineError::StaleTarget) => "staleTarget", + WorkerMutationError::Payload => "stalePayload", + _ => "invalidMutation", + }; + environment_protocol::MutationUnitOutcome::Failed { + code: code.to_string(), + phase: "accept".to_string(), + parameters: Vec::new(), + message: error.to_string(), + } +} + +impl From for SourcePayloadError { + fn from(error: SourceError) -> Self { + Self::Source(error) + } +} + +async fn send_payload_control_result( + writer: &ProtocolWriter, + request_id: u64, + result: Result, +) -> Result<(), String> { + match result { + Ok(message) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string()), + Err(error) => send_payload_error(writer, request_id, error).await, + } +} + +async fn send_payload_error( + writer: &ProtocolWriter, + request_id: u64, + error: SourcePayloadError, +) -> Result<(), String> { + let (code, phase) = match error { + SourcePayloadError::Source(SourceError::MissingSource) => ("staleSource", "payload"), + SourcePayloadError::Source(_) => ("invalidSource", "payload"), + SourcePayloadError::Payload(PayloadError::MissingPayload) + | SourcePayloadError::MissingPayload => ("missingPayload", "payload"), + SourcePayloadError::Payload(PayloadError::Engine( + environment_engine::payload::PayloadError::Cancelled, + )) => ("cancelled", "payload"), + SourcePayloadError::DeadlineExceeded => ("deadlineExceeded", "payload"), + SourcePayloadError::Payload(_) => ("stalePayload", "payload"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + }, + })) + .await + .map_err(|error| error.to_string()) +} + +async fn send_source_result( + writer: &ProtocolWriter, + request_id: u64, + result: Result, +) -> Result<(), String> { + match result { + Ok(message) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + .map_err(|error| error.to_string()), + Err(error) => send_source_error(writer, request_id, error).await, + } +} + +async fn send_source_error( + writer: &ProtocolWriter, + request_id: u64, + error: SourceError, +) -> Result<(), String> { + let (code, phase, parameters) = match error { + SourceError::GitUnavailable { message } => ( + "gitUnavailable", + "git", + vec![("message".to_string(), message)], + ), + SourceError::GitFailed { exit_code, stderr } => ( + "gitFailed", + "git", + vec![ + ( + "exitCode".to_string(), + exit_code.map(|code| code.to_string()).unwrap_or_default(), + ), + ("stderr".to_string(), stderr), + ], + ), + SourceError::DeadlineExceeded => ("deadlineExceeded", "source", Vec::new()), + SourceError::Cancelled => ("cancelled", "source", Vec::new()), + SourceError::MissingSource => ("staleSource", "source", Vec::new()), + SourceError::InvalidLocalSource + | SourceError::InvalidRelativePath + | SourceError::InvalidInventory => ("invalidSource", "source", Vec::new()), + SourceError::InvalidManagedBase | SourceError::Io(_) => ("sourceIo", "source", Vec::new()), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::Error { + code: code.to_string(), + phase: phase.to_string(), + parameters, + }, + })) + .await + .map_err(|error| error.to_string()) +} + +#[cfg(unix)] +fn posix_path(bytes: Vec) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + PathBuf::from(OsString::from_vec(bytes)) +} + +#[cfg(not(unix))] +fn posix_path(bytes: Vec) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(&bytes).into_owned()) +} + +async fn timeout_result( + deadline: Duration, + task: tokio::task::JoinHandle>, + cancelled: &Arc, + phase: &'static str, +) -> Result, String> { + match tokio::time::timeout(deadline, task).await { + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => { + cancelled.store(true, Ordering::Release); + Ok(Err(RequestError { + code: "deadlineExceeded", + phase, + })) + } + } +} + +async fn send_payload_result( + request_id: u64, + result: Result, + cancelled: Arc, + writer: ProtocolWriter, +) -> Result<(), String> +where + T: serde::Serialize, +{ + let externally_cancelled = cancelled.load(Ordering::Acquire) + && !matches!( + result, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + ); + if externally_cancelled { + return Ok(()); + } + match result { + Ok(response) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string()) + } + Err(error) => writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(error), + })) + .await + .map_err(|error| error.to_string()), + } +} + +async fn send_error( + writer: &ProtocolWriter, + request_id: u64, + code: &'static str, + phase: &'static str, +) -> Result<(), environment_protocol::WriterError> { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: error_message(RequestError { code, phase }), + })) + .await +} + +async fn discard_inbound( + prepared: &mut Option, + active: &mut Option, +) { + if let Some(prepared) = prepared.take() { + drop(prepared.file); + let _ = tokio::fs::remove_file(prepared.path).await; + } + if let Some(active) = active.take() { + let _ = tokio::fs::remove_file(active.path).await; + } +} + +fn cancel_all(active: &HashMap>) { + for cancelled in active.values() { + cancelled.store(true, Ordering::Release); + } +} + +#[cfg(unix)] +fn effective_user_id() -> u32 { + unsafe extern "C" { + fn geteuid() -> u32; + } + unsafe { geteuid() } +} + +#[cfg(not(unix))] +fn effective_user_id() -> u32 { + 0 +} diff --git a/src-tauri/crates/wsl-environment-worker/src/mutation.rs b/src-tauri/crates/wsl-environment-worker/src/mutation.rs new file mode 100644 index 00000000..8c569dae --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/mutation.rs @@ -0,0 +1,949 @@ +use std::fmt; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use environment_engine::linux_mutation::{ + validate_intents, EntryAction, EntryIntent, MutationError as EngineMutationError, + ParentIdentity, StagedMutation, +}; +use environment_engine::lock::{ + self as engine_lock, EntryMutation as EngineLockEntry, LockMutation as EngineLockMutation, + LockSchema as EngineLockSchema, +}; +use environment_protocol::{ + MutationCleanupToken, MutationEntryAction, MutationLock, MutationLockEntry, + MutationLockReceipt, MutationLockSchema, MutationUnitOutcome, MutationUnitRequest, + MAX_REQUEST_DEADLINE_MILLIS, +}; + +use crate::payload::PayloadManager; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryFile { + pub resource_id: String, + pub managed_root: PathBuf, + pub marker_bytes: Option>, + pub unsafe_root: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationRecoveryError { + InvalidBase, + InvalidResource, + UnsafeRoot, + StaleMarker, + Io { message: String }, +} + +impl fmt::Display for MutationRecoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for MutationRecoveryError {} + +impl From for MutationRecoveryError { + fn from(error: std::io::Error) -> Self { + Self::Io { + message: error.to_string(), + } + } +} + +pub struct MutationRecoveryStore { + namespace: PathBuf, +} + +pub struct MutationManager { + recovery: MutationRecoveryStore, +} + +pub struct AcceptedMutation { + request: MutationUnitRequest, + intents: Vec, + marker: Option, +} + +#[derive(Debug)] +pub enum WorkerMutationError { + InvalidRequest, + Payload, + Engine(EngineMutationError), + Lock(engine_lock::LockError), + Recovery(MutationRecoveryError), + LockCommitUncertain { message: String }, + Io(std::io::Error), + Json(serde_json::Error), +} + +impl fmt::Display for WorkerMutationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for WorkerMutationError {} + +impl From for WorkerMutationError { + fn from(error: MutationRecoveryError) -> Self { + Self::Recovery(error) + } +} + +impl From for WorkerMutationError { + fn from(error: EngineMutationError) -> Self { + Self::Engine(error) + } +} + +impl From for WorkerMutationError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for WorkerMutationError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +impl MutationManager { + pub fn new(namespace: PathBuf) -> Result { + Ok(Self { + recovery: MutationRecoveryStore::new(namespace)?, + }) + } + + pub fn accept( + &self, + request: MutationUnitRequest, + payloads: &PayloadManager, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + if request.entries.is_empty() + || request.resource_id.is_empty() + || request.operation_id.is_empty() + || request.unit_id.is_empty() + || request.deadline_millis == 0 + || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS + { + return Err(WorkerMutationError::InvalidRequest); + } + let intents = request + .entries + .iter() + .map(|entry| { + let action = match &entry.action { + MutationEntryAction::Keep => EntryAction::Keep, + MutationEntryAction::Materialize { payload_id } => EntryAction::Materialize { + payload_root: payloads + .payload_root(*payload_id) + .map_err(|_| WorkerMutationError::Payload)?, + }, + MutationEntryAction::Symlink { target } => { + let target = PathBuf::from(target); + if target.is_absolute() || target.as_os_str().is_empty() { + return Err(WorkerMutationError::InvalidRequest); + } + EntryAction::Symlink { target } + } + MutationEntryAction::Remove => EntryAction::Remove, + }; + Ok(EntryIntent { + destination: PathBuf::from(&entry.destination), + expected_parent: ParentIdentity { + device: entry.expected_anchor_device, + inode: entry.expected_anchor_inode, + }, + expected_fingerprint: entry.expected_fingerprint.clone(), + expected_content_hash: entry.expected_content_hash.clone(), + action, + }) + }) + .collect::, WorkerMutationError>>()?; + validate_intents(&intents, is_cancelled).map_err(WorkerMutationError::Engine)?; + let requires_recovery = request.lock.is_some() + || request + .entries + .iter() + .any(|entry| !matches!(entry.action, MutationEntryAction::Keep)); + let marker = if requires_recovery { + let marker: serde_json::Value = serde_json::from_slice(&request.initial_marker_json)?; + validate_initial_marker(&request, &marker)?; + self.recovery + .create(&request.resource_id, &request.initial_marker_json)?; + Some(marker) + } else { + None + }; + Ok(AcceptedMutation { + request, + intents, + marker, + }) + } + + pub fn execute( + &self, + mut accepted: AcceptedMutation, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + let intents = std::mem::take(&mut accepted.intents); + let mut staged = + match StagedMutation::stage(&accepted.request.resource_id, intents, &is_cancelled) { + Ok(staged) => staged, + Err(error) => { + if accepted.marker.is_some() { + self.remove_recovery(&accepted.request.resource_id)?; + } + return Ok(engine_failure("stage", error)); + } + }; + let mut lock_committed = false; + let transaction = (|| { + staged.swap(&is_cancelled)?; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("swapped")); + self.write_marker(&accepted)?; + staged.verify(|| false)?; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("verified")); + self.write_marker(&accepted)?; + let lock = match accepted.request.lock.as_ref().map(apply_lock).transpose() { + Ok(lock) => { + lock_committed = lock.is_some(); + lock + } + Err(error @ WorkerMutationError::LockCommitUncertain { .. }) => { + lock_committed = true; + return Err(error); + } + Err(error) => return Err(error), + }; + update_optional_marker_phase(&mut accepted.marker, "inProgress", Some("lockCommitted")); + self.write_marker(&accepted)?; + Ok::<_, WorkerMutationError>(lock) + })(); + let lock = match transaction { + Ok(lock) => lock, + Err(error) if lock_committed => { + let stage_cleanup = staged.cleanup_stages(); + update_optional_marker_phase( + &mut accepted.marker, + "recoveryRequired", + Some("lockCommitted"), + ); + let marker_error = self.write_marker(&accepted).err(); + let mut message = marker_error.map_or_else( + || error.to_string(), + |marker_error| format!("{error}; {marker_error}"), + ); + if !stage_cleanup.is_empty() { + message.push_str(&format!( + "; stage cleanup failed: {}", + stage_cleanup.join("; ") + )); + } + return Ok(MutationUnitOutcome::RecoveryRequired { + resource_id: accepted.request.resource_id, + message, + }); + } + Err(error) => { + let primary = error.to_string(); + let (code, parameters) = transaction_error_fields(&error); + return match staged.restore() { + Ok(()) => { + let _ = staged.cleanup(); + if accepted.marker.is_some() { + self.remove_recovery(&accepted.request.resource_id)?; + } + Ok(MutationUnitOutcome::Failed { + code, + phase: "commit".to_string(), + parameters, + message: primary, + }) + } + Err(restore) => { + let stage_cleanup = staged.cleanup_stages(); + update_optional_marker_phase( + &mut accepted.marker, + "recoveryRequired", + Some("restoreFailed"), + ); + if accepted.marker.is_some() { + self.write_marker(&accepted)?; + let mut message = format!("{primary}; {restore}"); + if !stage_cleanup.is_empty() { + message.push_str(&format!( + "; stage cleanup failed: {}", + stage_cleanup.join("; ") + )); + } + Ok(MutationUnitOutcome::RecoveryRequired { + resource_id: accepted.request.resource_id, + message, + }) + } else { + Ok(MutationUnitOutcome::Failed { + code: "restoreFailed".to_string(), + phase: "restore".to_string(), + parameters: Vec::new(), + message: format!("{primary}; {restore}"), + }) + } + } + }; + } + }; + update_optional_marker_phase(&mut accepted.marker, "cleanupOnly", None); + self.write_marker(&accepted)?; + let cleanup = if accepted.marker.is_some() { + Some(MutationCleanupToken { + resource_id: accepted.request.resource_id.clone(), + marker_sha256: self.recovery.marker_digest(&accepted.request.resource_id)?, + }) + } else { + None + }; + Ok(MutationUnitOutcome::Succeeded { lock, cleanup }) + } + + pub fn acknowledge(&self, cleanup: &MutationCleanupToken) -> Result<(), WorkerMutationError> { + self.recovery + .acknowledge(&cleanup.resource_id, &cleanup.marker_sha256)?; + Ok(()) + } + + pub fn requires_acceptance(accepted: &AcceptedMutation) -> bool { + accepted.marker.is_some() + } + + pub fn recovery_store(&self) -> &MutationRecoveryStore { + &self.recovery + } + + fn write_marker(&self, accepted: &AcceptedMutation) -> Result<(), WorkerMutationError> { + let Some(marker) = &accepted.marker else { + return Ok(()); + }; + self.recovery.update( + &accepted.request.resource_id, + &serde_json::to_vec_pretty(marker)?, + )?; + Ok(()) + } + + fn remove_recovery(&self, resource_id: &str) -> Result<(), WorkerMutationError> { + self.recovery.remove(resource_id)?; + Ok(()) + } +} + +impl MutationRecoveryStore { + pub fn new(namespace: PathBuf) -> Result { + if !namespace.is_absolute() { + return Err(MutationRecoveryError::InvalidBase); + } + fs::create_dir_all(&namespace)?; + let namespace = fs::canonicalize(namespace)?; + if !namespace.is_dir() { + return Err(MutationRecoveryError::InvalidBase); + } + Ok(Self { namespace }) + } + + pub fn list(&self) -> Result, MutationRecoveryError> { + let mut entries = fs::read_dir(&self.namespace)? + .collect::, _>>()? + .into_iter() + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("skill-deck-operation-") + }) + .collect::>(); + entries.sort_by_key(fs::DirEntry::file_name); + Ok(entries + .into_iter() + .map(|entry| { + let root = entry.path(); + let resource_id = entry + .file_name() + .to_string_lossy() + .trim_start_matches("skill-deck-operation-") + .to_string(); + let safe = self.validate_root(&root, &resource_id).is_ok(); + let marker_bytes = safe + .then(|| fs::read(root.join("recovery.json"))) + .transpose() + .ok() + .flatten(); + RecoveryFile { + resource_id, + managed_root: root, + marker_bytes, + unsafe_root: !safe, + } + }) + .collect()) + } + + pub fn create( + &self, + resource_id: &str, + marker_bytes: &[u8], + ) -> Result { + validate_resource_id(resource_id)?; + if marker_bytes.is_empty() { + return Err(MutationRecoveryError::StaleMarker); + } + let root = self.root(resource_id); + if fs::symlink_metadata(&root).is_ok() { + return Err(MutationRecoveryError::StaleMarker); + } + fs::create_dir(&root)?; + set_private_directory(&root)?; + let result = (|| { + fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + )?; + write_atomic(&root.join("recovery.json"), marker_bytes) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&root); + } + result?; + Ok(root) + } + + pub fn update( + &self, + resource_id: &str, + marker_bytes: &[u8], + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + if marker_bytes.is_empty() { + return Err(MutationRecoveryError::StaleMarker); + } + write_atomic(&root.join("recovery.json"), marker_bytes) + } + + pub fn cleanup( + &self, + resource_id: &str, + expected_marker: &[u8], + backups: &[PathBuf], + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + let marker = fs::read(root.join("recovery.json"))?; + if marker != expected_marker { + return Err(MutationRecoveryError::StaleMarker); + } + let expected_backups = marker_backups(&marker)?; + if expected_backups + .iter() + .map(|(_, backup)| backup) + .ne(backups.iter()) + { + return Err(MutationRecoveryError::StaleMarker); + } + for (destination, backup) in &expected_backups { + validate_backup(resource_id, destination, backup, &root)?; + } + for (_, backup) in &expected_backups { + remove_no_follow(&stage_path_for_backup(resource_id, backup)?)?; + } + for (_, backup) in expected_backups { + remove_no_follow(&backup)?; + } + fs::remove_dir_all(root)?; + Ok(()) + } + + pub fn remove(&self, resource_id: &str) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + fs::remove_dir_all(root)?; + Ok(()) + } + + pub fn marker_digest(&self, resource_id: &str) -> Result { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + Ok(format!( + "sha256:{:x}", + Sha256::digest(fs::read(root.join("recovery.json"))?) + )) + } + + pub fn acknowledge( + &self, + resource_id: &str, + expected_digest: &str, + ) -> Result<(), MutationRecoveryError> { + let root = self.root(resource_id); + self.validate_root(&root, resource_id)?; + let marker = fs::read(root.join("recovery.json"))?; + let actual = format!("sha256:{:x}", Sha256::digest(&marker)); + if actual != expected_digest { + return Err(MutationRecoveryError::StaleMarker); + } + let backups = marker_backups(&marker)? + .into_iter() + .map(|(_, backup)| backup) + .collect::>(); + self.cleanup(resource_id, &marker, &backups) + } + + fn root(&self, resource_id: &str) -> PathBuf { + self.namespace + .join(format!("skill-deck-operation-{resource_id}")) + } + + fn validate_root(&self, root: &Path, resource_id: &str) -> Result<(), MutationRecoveryError> { + validate_resource_id(resource_id)?; + if root != self.root(resource_id) { + return Err(MutationRecoveryError::UnsafeRoot); + } + let metadata = fs::symlink_metadata(root)?; + let owner = root.join(".skill-deck-owner"); + let owner_metadata = fs::symlink_metadata(&owner)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || !owner_metadata.is_file() + || owner_metadata.file_type().is_symlink() + || fs::read_to_string(owner)? != format!("1\n{resource_id}\n") + { + return Err(MutationRecoveryError::UnsafeRoot); + } + Ok(()) + } +} + +fn validate_initial_marker( + request: &MutationUnitRequest, + marker: &serde_json::Value, +) -> Result<(), WorkerMutationError> { + if marker.get("resourceId").and_then(serde_json::Value::as_str) != Some(&request.resource_id) + || marker + .get("operationId") + .and_then(serde_json::Value::as_str) + != Some(&request.operation_id) + || marker.get("unitId").and_then(serde_json::Value::as_str) != Some(&request.unit_id) + || marker.get("kind").and_then(serde_json::Value::as_str) != Some("inProgress") + { + return Err(WorkerMutationError::InvalidRequest); + } + let marker_entries = marker + .get("entries") + .and_then(serde_json::Value::as_array) + .ok_or(WorkerMutationError::InvalidRequest)?; + let changed = request + .entries + .iter() + .enumerate() + .filter(|(_, entry)| !matches!(entry.action, MutationEntryAction::Keep)) + .collect::>(); + let evidenced = if changed.is_empty() && request.lock.is_some() { + request.entries.iter().enumerate().take(1).collect() + } else { + changed + }; + if marker_entries.len() != evidenced.len() { + return Err(WorkerMutationError::InvalidRequest); + } + for (marker_entry, (index, request_entry)) in marker_entries.iter().zip(evidenced) { + let destination = marker_entry + .get("destination") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str); + let backup = marker_entry + .get("backup") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str); + let destination_path = Path::new(&request_entry.destination); + let expected_backup = if matches!(request_entry.action, MutationEntryAction::Keep) { + None + } else { + destination_path.parent().map(|parent| { + parent.join(format!( + ".skill-deck-backup-{}-{index:06}", + request.resource_id + )) + }) + }; + if destination != Some(request_entry.destination.as_str()) + || backup.map(Path::new) != expected_backup.as_deref() + { + return Err(WorkerMutationError::InvalidRequest); + } + } + Ok(()) +} + +fn update_marker_phase(marker: &mut serde_json::Value, kind: &str, phase: Option<&str>) { + marker["kind"] = serde_json::Value::String(kind.to_string()); + if let (Some(phase), Some(entries)) = ( + phase, + marker + .get_mut("entries") + .and_then(serde_json::Value::as_array_mut), + ) { + for entry in entries { + entry["phase"] = serde_json::Value::String(phase.to_string()); + } + } +} + +fn update_optional_marker_phase( + marker: &mut Option, + kind: &str, + phase: Option<&str>, +) { + if let Some(marker) = marker { + update_marker_phase(marker, kind, phase); + } +} + +fn apply_lock(lock: &MutationLock) -> Result { + let target = Path::new(&lock.target); + if !target.is_absolute() + || lock + .legacy_target + .as_deref() + .is_some_and(|path| !Path::new(path).is_absolute()) + { + return Err(WorkerMutationError::InvalidRequest); + } + let current = read_optional(target)?; + let legacy = match (¤t, &lock.legacy_target) { + (None, Some(path)) => read_optional(Path::new(path))?, + _ => None, + }; + let mutation = engine_lock_mutation(lock)?; + let applied = engine_lock::apply(current.as_deref(), legacy.as_deref(), &mutation) + .map_err(WorkerMutationError::Lock)?; + write_document_atomic(target, &applied.bytes)?; + Ok(MutationLockReceipt { + entries_json: applied + .receipt + .entries + .into_iter() + .map(|(key, value)| { + value + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map(|value| (key, value)) + }) + .collect::>()?, + roots_json: applied + .receipt + .roots + .into_iter() + .map(|(field, value)| { + value + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map(|value| (field, value)) + }) + .collect::>()?, + }) +} + +fn engine_lock_mutation(lock: &MutationLock) -> Result { + let parse = |bytes: &[u8]| serde_json::from_slice(bytes).map_err(WorkerMutationError::from); + Ok(EngineLockMutation { + schema: match lock.schema { + MutationLockSchema::Global => EngineLockSchema::Global, + MutationLockSchema::Project => EngineLockSchema::Project, + }, + entry: match &lock.entry { + MutationLockEntry::Replace { + key, + replacement_json, + } => EngineLockEntry::Replace { + key: key.clone(), + replacement: parse(replacement_json)?, + }, + MutationLockEntry::Remove { key } => EngineLockEntry::Remove { key: key.clone() }, + MutationLockEntry::MoveAndReplace { + from, + to, + replacement_json, + } => EngineLockEntry::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement: parse(replacement_json)?, + }, + }, + root_replacements: lock + .root_replacements_json + .iter() + .map(|(field, bytes)| parse(bytes).map(|value| (field.clone(), value))) + .collect::>()?, + expected_entries: lock + .expected_entries_json + .iter() + .map(|(key, bytes)| { + bytes + .as_deref() + .map(parse) + .transpose() + .map(|value| (key.clone(), value)) + }) + .collect::>()?, + expected_roots: lock + .expected_roots_json + .iter() + .map(|(field, bytes)| { + bytes + .as_deref() + .map(parse) + .transpose() + .map(|value| (field.clone(), value)) + }) + .collect::>()?, + }) +} + +fn read_optional(path: &Path) -> Result>, WorkerMutationError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn write_document_atomic(path: &Path, bytes: &[u8]) -> Result<(), WorkerMutationError> { + let parent = path.parent().ok_or(WorkerMutationError::InvalidRequest)?; + fs::create_dir_all(parent)?; + let legacy_backup = PathBuf::from(format!("{}.bak", path.display())); + match fs::remove_file(&legacy_backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let temporary = parent.join(format!(".skill-deck-document.{}", std::process::id())); + if fs::symlink_metadata(&temporary).is_ok() { + return Err(WorkerMutationError::InvalidRequest); + } + let mut committed = false; + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + set_private_file(&temporary)?; + use std::io::Write; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + committed = true; + fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + match result { + Ok(()) => Ok(()), + Err(error) if committed => Err(WorkerMutationError::LockCommitUncertain { + message: error.to_string(), + }), + Err(error) => Err(error.into()), + } +} + +fn engine_failure(phase: &str, error: EngineMutationError) -> MutationUnitOutcome { + if error == EngineMutationError::Cancelled { + MutationUnitOutcome::Cancelled + } else { + MutationUnitOutcome::Failed { + code: match error { + EngineMutationError::StaleTarget => "staleTarget", + EngineMutationError::InvalidPayload => "stalePayload", + _ => "executionFailed", + } + .to_string(), + phase: phase.to_string(), + parameters: Vec::new(), + message: error.to_string(), + } + } +} + +fn transaction_error_fields(error: &WorkerMutationError) -> (String, Vec<(String, String)>) { + match error { + WorkerMutationError::Lock(engine_lock::LockError::EntryConflict { key }) => ( + "lockConflictSkill".to_string(), + vec![("skillName".to_string(), key.clone())], + ), + WorkerMutationError::Lock(engine_lock::LockError::RootConflict { field }) => ( + "lockConflictRoot".to_string(), + vec![("field".to_string(), field.clone())], + ), + _ => ("transactionFailed".to_string(), Vec::new()), + } +} + +fn validate_resource_id(resource_id: &str) -> Result<(), MutationRecoveryError> { + if resource_id.is_empty() + || resource_id.len() > 128 + || !resource_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + Err(MutationRecoveryError::InvalidResource) + } else { + Ok(()) + } +} + +fn validate_backup( + resource_id: &str, + destination: &Path, + backup: &Path, + root: &Path, +) -> Result<(), MutationRecoveryError> { + let name = backup + .file_name() + .and_then(|name| name.to_str()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + if !backup.is_absolute() + || !destination.is_absolute() + || unsafe_lexical_path(backup) + || unsafe_lexical_path(destination) + || backup == root + || backup == destination + || backup.parent() != destination.parent() + || !name.starts_with(&format!(".skill-deck-backup-{resource_id}-")) + { + return Err(MutationRecoveryError::UnsafeRoot); + } + Ok(()) +} + +fn marker_backups(marker: &[u8]) -> Result, MutationRecoveryError> { + let value: serde_json::Value = + serde_json::from_slice(marker).map_err(|_| MutationRecoveryError::StaleMarker)?; + value + .get("entries") + .and_then(serde_json::Value::as_array) + .ok_or(MutationRecoveryError::StaleMarker)? + .iter() + .filter_map(|entry| { + let backup = entry.get("backup")?; + (!backup.is_null()).then_some((entry, backup)) + }) + .map(|(entry, backup)| { + let destination = entry + .get("destination") + .and_then(|value| value.get("nativePath")) + .and_then(serde_json::Value::as_str) + .ok_or(MutationRecoveryError::StaleMarker)?; + let backup = backup + .get("nativePath") + .and_then(serde_json::Value::as_str) + .ok_or(MutationRecoveryError::StaleMarker)?; + Ok((PathBuf::from(destination), PathBuf::from(backup))) + }) + .collect() +} + +fn stage_path_for_backup( + resource_id: &str, + backup: &Path, +) -> Result { + let name = backup + .file_name() + .and_then(|name| name.to_str()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + let suffix = name + .strip_prefix(&format!(".skill-deck-backup-{resource_id}-")) + .filter(|suffix| !suffix.is_empty()) + .ok_or(MutationRecoveryError::UnsafeRoot)?; + Ok(backup.with_file_name(format!(".skill-deck-stage-{resource_id}-{suffix}"))) +} + +fn unsafe_lexical_path(path: &Path) -> bool { + path.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), MutationRecoveryError> { + let parent = path.parent().ok_or(MutationRecoveryError::UnsafeRoot)?; + let temporary = parent.join(format!(".recovery-{}", std::process::id())); + if fs::symlink_metadata(&temporary).is_ok() { + return Err(MutationRecoveryError::StaleMarker); + } + let result = (|| { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + set_private_file(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + fs::File::open(parent)?.sync_all()?; + Ok::<_, std::io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.map_err(Into::into) +} + +fn remove_no_follow(path: &Path) -> Result<(), MutationRecoveryError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(path)? + } + Ok(_) => fs::remove_file(path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +#[cfg(unix)] +fn set_private_directory(path: &Path) -> Result<(), MutationRecoveryError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_directory(_path: &Path) -> Result<(), MutationRecoveryError> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_file(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_private_file(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/payload.rs b/src-tauri/crates/wsl-environment-worker/src/payload.rs new file mode 100644 index 00000000..542e5ca1 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/payload.rs @@ -0,0 +1,657 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use environment_engine::payload::{ + build_payload_with_cancel, read_blob, verify_payload, BuiltPayload, + PayloadEntryKind as EngineEntryKind, PayloadError as EngineError, + PayloadManifest as EngineManifest, +}; +use environment_protocol::{ + PayloadCleanupResponse, PayloadCleanupWarning, PayloadEntry, PayloadEntryKind, PayloadManifest, + PayloadReadyResponse, +}; + +const OWNER_FILE: &str = ".skill-deck-owner"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedPayload { + pub id: u64, + pub root: PathBuf, + pub manifest: PayloadManifest, + pub total_bytes: u64, + pub computed_hash: Option, +} + +impl ManagedPayload { + pub fn into_response(self) -> PayloadReadyResponse { + PayloadReadyResponse { + payload_id: self.id, + manifest: self.manifest, + total_bytes: self.total_bytes, + computed_hash: self.computed_hash, + } + } +} + +#[derive(Debug)] +pub enum PayloadError { + InvalidBase, + InvalidSession, + InvalidPayloadName, + MissingPayload, + StalePayload, + Engine(EngineError), + Json(serde_json::Error), + Io(std::io::Error), +} + +impl fmt::Display for PayloadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for PayloadError {} + +impl From for PayloadError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PayloadError { + fn from(error: EngineError) -> Self { + Self::Engine(error) + } +} + +impl From for PayloadError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +struct PayloadRecord { + root: PathBuf, +} + +pub struct PayloadManager { + base: PathBuf, + next_id: u64, + payloads: HashMap, + uploads: HashMap, +} + +struct UploadRecord { + stage: PathBuf, + final_root: PathBuf, +} + +pub struct PreparedPayloadFile { + pub path: PathBuf, + pub file: fs::File, +} + +impl PayloadManager { + pub fn new(base: PathBuf) -> Result { + if !base.is_absolute() { + return Err(PayloadError::InvalidBase); + } + fs::create_dir_all(&base)?; + let base = fs::canonicalize(base)?; + if !base.is_dir() { + return Err(PayloadError::InvalidBase); + } + Ok(Self { + base, + next_id: 1, + payloads: HashMap::new(), + uploads: HashMap::new(), + }) + } + + pub fn acquire_from_source( + &mut self, + session_id: &str, + payload_name: &str, + source_root: &Path, + ) -> Result { + self.acquire_from_source_with_cancel(session_id, payload_name, source_root, || false) + } + + pub fn acquire_from_source_with_cancel( + &mut self, + session_id: &str, + payload_name: &str, + source_root: &Path, + is_cancelled: F, + ) -> Result + where + F: Fn() -> bool, + { + let session_root = self.ensure_session(session_id)?; + validate_payload_name(payload_name)?; + let payload_root = session_root.join(payload_name); + if fs::symlink_metadata(&payload_root).is_ok() { + return Err(PayloadError::StalePayload); + } + let id = self.allocate_id()?; + let stage = session_root.join(format!(".stage-{payload_name}-{id}")); + if fs::symlink_metadata(&stage).is_ok() { + return Err(PayloadError::StalePayload); + } + let built = build_payload_with_cancel(source_root, &stage, is_cancelled)?; + if let Err(error) = fs::rename(&stage, &payload_root) { + let _ = fs::remove_dir_all(&stage); + return Err(error.into()); + } + Ok(self.register_built(id, payload_root, built)) + } + + pub fn verify( + &mut self, + session_id: &str, + payload_name: &str, + ) -> Result, PayloadError> { + let session_root = self.session_root(session_id)?; + validate_payload_name(payload_name)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + let payload_root = session_root.join(payload_name); + match fs::symlink_metadata(&payload_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + Ok(_) => {} + } + let manifest = verify_payload(&payload_root)?; + let total_bytes = unique_total_bytes(&manifest); + let id = self.allocate_id()?; + self.payloads.insert( + id, + PayloadRecord { + root: payload_root.clone(), + }, + ); + Ok(Some(ManagedPayload { + id, + root: payload_root, + manifest: map_manifest(manifest), + total_bytes, + computed_hash: None, + })) + } + + pub fn begin_upload( + &mut self, + session_id: &str, + payload_name: &str, + ) -> Result { + let session_root = self.ensure_session(session_id)?; + validate_payload_name(payload_name)?; + let final_root = session_root.join(payload_name); + let stage = session_root.join(format!("{payload_name}.upload")); + if fs::symlink_metadata(&final_root).is_ok() || fs::symlink_metadata(&stage).is_ok() { + return Err(PayloadError::StalePayload); + } + fs::create_dir(&stage)?; + set_private_directory(&stage)?; + fs::create_dir(stage.join("blobs"))?; + let upload_id = self.allocate_id()?; + self.uploads + .insert(upload_id, UploadRecord { stage, final_root }); + Ok(upload_id) + } + + pub fn prepare_blob( + &self, + upload_id: u64, + blob_id: &str, + ) -> Result { + if !valid_blob_id(blob_id) { + return Err(PayloadError::StalePayload); + } + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let path = upload.stage.join(format!(".incoming-blob-{blob_id}")); + prepare_file(path) + } + + pub fn commit_blob( + &self, + upload_id: u64, + blob_id: &str, + incoming: PathBuf, + ) -> Result<(), PayloadError> { + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let expected = upload.stage.join(format!(".incoming-blob-{blob_id}")); + if incoming != expected || file_sha256(&incoming)? != blob_id { + return Err(PayloadError::StalePayload); + } + let destination = upload.stage.join("blobs").join(blob_id); + if fs::symlink_metadata(&destination).is_ok() { + return Err(PayloadError::StalePayload); + } + fs::rename(incoming, &destination)?; + set_private_file(&destination)?; + Ok(()) + } + + pub fn prepare_manifest(&self, upload_id: u64) -> Result { + let upload = self + .uploads + .get(&upload_id) + .ok_or(PayloadError::StalePayload)?; + prepare_file(upload.stage.join(".incoming-manifest")) + } + + pub fn finalize_upload( + &mut self, + upload_id: u64, + incoming_manifest: PathBuf, + ) -> Result { + let upload = self + .uploads + .remove(&upload_id) + .ok_or(PayloadError::StalePayload)?; + let result = finalize_upload_record(&upload, incoming_manifest).and_then(|manifest| { + fs::rename(&upload.stage, &upload.final_root)?; + let id = self.allocate_id()?; + let total_bytes = unique_total_bytes(&manifest); + self.payloads.insert( + id, + PayloadRecord { + root: upload.final_root.clone(), + }, + ); + Ok(ManagedPayload { + id, + root: upload.final_root.clone(), + manifest: map_manifest(manifest), + total_bytes, + computed_hash: None, + }) + }); + if result.is_err() { + let _ = fs::remove_dir_all(&upload.stage); + } + result + } + + pub fn abort_upload(&mut self, upload_id: u64) { + if let Some(upload) = self.uploads.remove(&upload_id) { + let _ = fs::remove_dir_all(upload.stage); + } + } + + pub fn read_blob( + &self, + payload_id: u64, + blob_id: &str, + ) -> Result, PayloadError> { + let payload = self + .payloads + .get(&payload_id) + .ok_or(PayloadError::MissingPayload)?; + read_blob(&payload.root, blob_id).map_err(Into::into) + } + + pub fn payload_root(&self, payload_id: u64) -> Result { + self.payloads + .get(&payload_id) + .map(|payload| payload.root.clone()) + .ok_or(PayloadError::MissingPayload) + } + + pub fn remove(&mut self, session_id: &str, payload_name: &str) -> Result<(), PayloadError> { + let session_root = self.session_root(session_id)?; + validate_payload_name(payload_name)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + let payload_root = session_root.join(payload_name); + let upload_ids = self + .uploads + .iter() + .filter_map(|(upload_id, upload)| { + (upload.final_root == payload_root).then_some(*upload_id) + }) + .collect::>(); + for upload_id in upload_ids { + self.abort_upload(upload_id); + } + match fs::symlink_metadata(&payload_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + fs::remove_dir_all(&payload_root)?; + } + Ok(_) => return Err(PayloadError::StalePayload), + } + self.payloads + .retain(|_, record| record.root != payload_root); + Ok(()) + } + + pub fn remove_session(&mut self, session_id: &str) -> Result<(), PayloadError> { + let session_root = self.session_root(session_id)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + fs::remove_dir_all(&session_root)?; + self.payloads + .retain(|_, record| !record.root.starts_with(&session_root)); + self.uploads + .retain(|_, upload| !upload.final_root.starts_with(&session_root)); + Ok(()) + } + + pub fn sweep_orphans( + &mut self, + protected_session_ids: &[String], + ) -> Result { + let protected = protected_session_ids.iter().collect::>(); + let mut report = PayloadCleanupResponse { + removed_sessions: 0, + protected_sessions: 0, + retained_external_bytes: 0, + cleanup_blocked: false, + warnings: Vec::new(), + }; + for entry in fs::read_dir(&self.base)?.collect::, _>>()? { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(session_id) = name.strip_prefix("skill-deck-source-") else { + continue; + }; + let path = entry.path(); + if self.validate_session(&path, session_id).is_err() { + retain_external(&mut report, &path, &name, "invalidMarker"); + continue; + } + if protected.contains(&session_id.to_string()) { + report.protected_sessions = report.protected_sessions.saturating_add(1); + continue; + } + match fs::remove_dir_all(&path) { + Ok(()) => { + report.removed_sessions = report.removed_sessions.saturating_add(1); + self.payloads + .retain(|_, record| !record.root.starts_with(&path)); + } + Err(error) => retain_external_with_details( + &mut report, + &path, + &name, + "deleteFailed", + Some(error.to_string()), + ), + } + } + Ok(report) + } + + fn ensure_session(&self, session_id: &str) -> Result { + let session_root = self.session_root(session_id)?; + match fs::symlink_metadata(&session_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&session_root)?; + set_private_directory(&session_root)?; + fs::write(session_root.join(OWNER_FILE), format!("1\n{session_id}\n"))?; + } + Err(error) => return Err(error.into()), + Ok(_) => self.validate_session(&session_root, session_id)?, + } + Ok(session_root) + } + + fn session_root(&self, session_id: &str) -> Result { + if session_id.is_empty() + || session_id.len() > 128 + || !session_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PayloadError::InvalidSession); + } + Ok(self.base.join(format!("skill-deck-source-{session_id}"))) + } + + fn validate_session(&self, root: &Path, session_id: &str) -> Result<(), PayloadError> { + let metadata = fs::symlink_metadata(root)?; + let marker = root.join(OWNER_FILE); + let marker_metadata = fs::symlink_metadata(&marker)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || !marker_metadata.is_file() + || marker_metadata.file_type().is_symlink() + || fs::read_to_string(marker)? != format!("1\n{session_id}\n") + { + return Err(PayloadError::StalePayload); + } + Ok(()) + } + + fn allocate_id(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(PayloadError::StalePayload)?; + Ok(id) + } + + fn register_built(&mut self, id: u64, root: PathBuf, built: BuiltPayload) -> ManagedPayload { + self.payloads + .insert(id, PayloadRecord { root: root.clone() }); + ManagedPayload { + id, + root, + manifest: map_manifest(built.manifest), + total_bytes: built.total_bytes, + computed_hash: Some(built.computed_hash), + } + } +} + +impl Drop for PayloadManager { + fn drop(&mut self) { + for upload in self.uploads.values() { + let _ = fs::remove_dir_all(&upload.stage); + } + } +} + +fn prepare_file(path: PathBuf) -> Result { + if fs::symlink_metadata(&path).is_ok() { + return Err(PayloadError::StalePayload); + } + let file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + set_private_file(&path)?; + Ok(PreparedPayloadFile { path, file }) +} + +fn finalize_upload_record( + upload: &UploadRecord, + incoming_manifest: PathBuf, +) -> Result { + if incoming_manifest != upload.stage.join(".incoming-manifest") { + return Err(PayloadError::StalePayload); + } + let manifest: EngineManifest = serde_json::from_reader(fs::File::open(&incoming_manifest)?)?; + let expected_blobs = manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.clone()) + .collect::>(); + let actual_blobs = fs::read_dir(upload.stage.join("blobs"))? + .map(|entry| { + let entry = entry?; + let metadata = entry.metadata()?; + if !metadata.is_file() || entry.file_type()?.is_symlink() { + return Err(std::io::Error::other("payload blob is not a regular file")); + } + Ok(entry.file_name().to_string_lossy().into_owned()) + }) + .collect::, std::io::Error>>()?; + if actual_blobs != expected_blobs { + return Err(PayloadError::StalePayload); + } + let mut blob_list = fs::File::create(upload.stage.join("blob-list"))?; + use std::io::Write; + for blob_id in &expected_blobs { + writeln!(blob_list, "{blob_id}")?; + } + fs::rename(incoming_manifest, upload.stage.join("manifest.json"))?; + let verified = verify_payload(&upload.stage)?; + if verified != manifest { + return Err(PayloadError::StalePayload); + } + Ok(manifest) +} + +fn validate_payload_name(payload_name: &str) -> Result<(), PayloadError> { + if !payload_name.starts_with("payload-") + || payload_name.len() <= "payload-".len() + || !payload_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(PayloadError::InvalidPayloadName); + } + Ok(()) +} + +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn file_sha256(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + use std::io::Read; + + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + return Ok(format!("{:x}", hasher.finalize())); + } + hasher.update(&buffer[..read]); + } +} + +fn map_manifest(manifest: EngineManifest) -> PayloadManifest { + PayloadManifest { + entries: manifest + .entries + .into_iter() + .map(|entry| PayloadEntry { + relative_path: entry.relative_path, + kind: match entry.kind { + EngineEntryKind::File => PayloadEntryKind::File, + EngineEntryKind::Directory => PayloadEntryKind::Directory, + }, + blob_id: entry.blob_id, + content_hash: entry.content_hash, + size: entry.size, + executable: entry.executable, + }) + .collect(), + payload_root_hash: manifest.payload_root_hash, + payload_id: manifest.payload_id, + } +} + +fn unique_total_bytes(manifest: &EngineManifest) -> u64 { + manifest + .entries + .iter() + .filter_map(|entry| entry.blob_id.as_ref().map(|blob| (blob, entry.size))) + .collect::>() + .values() + .copied() + .sum() +} + +fn retain_external(report: &mut PayloadCleanupResponse, path: &Path, name: &str, code: &str) { + retain_external_with_details(report, path, name, code, None); +} + +fn retain_external_with_details( + report: &mut PayloadCleanupResponse, + path: &Path, + name: &str, + code: &str, + technical_details: Option, +) { + report.cleanup_blocked = true; + match directory_size(path) { + Ok(size) => { + report.retained_external_bytes = report.retained_external_bytes.saturating_add(size) + } + Err(_) => report.warnings.push(PayloadCleanupWarning { + code: "sizeUnavailable".to_string(), + candidate_name: Some(name.to_string()), + technical_details: None, + }), + } + report.warnings.push(PayloadCleanupWarning { + code: code.to_string(), + candidate_name: Some(name.to_string()), + technical_details, + }); +} + +fn directory_size(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || metadata.is_file() { + return Ok(metadata.len()); + } + let mut size = metadata.len(); + for entry in fs::read_dir(path)? { + size = size.saturating_add(directory_size(&entry?.path())?); + } + Ok(size) +} + +#[cfg(unix)] +fn set_private_directory(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(unix)] +fn set_private_file(path: &Path) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_private_directory(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_file(_path: &Path) -> Result<(), std::io::Error> { + Ok(()) +} diff --git a/src-tauri/crates/wsl-environment-worker/src/source.rs b/src-tauri/crates/wsl-environment-worker/src/source.rs new file mode 100644 index 00000000..1d20f4e5 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/src/source.rs @@ -0,0 +1,593 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use environment_engine::payload::source_metadata_fingerprint; +#[cfg(target_os = "linux")] +use environment_engine::source_inventory::{ + scan_source_with_cancel, SourceEntryError as EngineEntryError, + SourceEntryKind as EngineEntryKind, SourceInventoryRequest, SourceRoot, + SourceScanMode as EngineScanMode, +}; +#[cfg(target_os = "linux")] +use environment_protocol::{SourceEntry, SourceEntryErrorCode, SourceEntryKind}; +use environment_protocol::{SourceScanRequest, SourceScanResponse}; + +const GIT_STDERR_LIMIT: usize = 256 * 1024; +const GIT_STDOUT_LIMIT: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitSourceOptions { + pub url: String, + pub git_ref: Option, + pub proxy: Option, + pub deadline: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedSource { + pub id: u64, + pub root: PathBuf, + pub revision: Option, +} + +#[derive(Debug)] +pub enum SourceError { + InvalidManagedBase, + InvalidLocalSource, + InvalidRelativePath, + InvalidInventory, + MissingSource, + GitUnavailable { + message: String, + }, + GitFailed { + exit_code: Option, + stderr: String, + }, + DeadlineExceeded, + Cancelled, + Io(std::io::Error), +} + +#[cfg(target_os = "linux")] +pub fn scan_source( + manager: &SourceManager, + request: SourceScanRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let roots = request + .roots + .into_iter() + .map(|root| { + let relative = PathBuf::from(OsString::from_vec(root.relative_path)); + manager + .resolve(request.source_id, &relative) + .map(|path| SourceRoot { + path, + stat_only: root.stat_only, + }) + }) + .collect::, _>>()?; + let response = scan_source_with_cancel( + &SourceInventoryRequest { + roots, + mode: match request.mode { + environment_protocol::SourceScanMode::Recursive => EngineScanMode::Recursive, + environment_protocol::SourceScanMode::PriorityDirectories => { + EngineScanMode::PriorityDirectories + } + }, + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + }, + is_cancelled, + ) + .map_err(|_| SourceError::InvalidInventory)?; + Ok(SourceScanResponse { + entries: response + .entries + .into_iter() + .map(|entry| SourceEntry { + root_index: entry.root_index, + relative_path: entry.relative_path.as_os_str().as_bytes().to_vec(), + kind: match entry.kind { + EngineEntryKind::Missing => SourceEntryKind::Missing, + EngineEntryKind::File => SourceEntryKind::File, + EngineEntryKind::Directory => SourceEntryKind::Directory, + EngineEntryKind::Symlink => SourceEntryKind::Symlink, + EngineEntryKind::Other => SourceEntryKind::Other, + }, + link_target: entry + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + content_bytes: entry.content_bytes, + truncated: entry.truncated, + error_code: entry.error.map(|error| match error { + EngineEntryError::PathUnavailable => SourceEntryErrorCode::PathUnavailable, + EngineEntryError::ReadFailed => SourceEntryErrorCode::ReadFailed, + EngineEntryError::ReadLinkFailed => SourceEntryErrorCode::ReadLinkFailed, + }), + }) + .collect(), + total_content_bytes: response.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn scan_source( + _manager: &SourceManager, + _request: SourceScanRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(SourceError::InvalidInventory) +} + +impl fmt::Display for SourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for SourceError {} + +impl From for SourceError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +struct SourceRecord { + root: PathBuf, + cleanup_root: Option, + revision: Option, +} + +pub struct SourceManager { + managed_base: PathBuf, + next_id: u64, + sources: HashMap, +} + +impl SourceManager { + pub fn new(managed_base: PathBuf) -> Result { + if !managed_base.is_absolute() { + return Err(SourceError::InvalidManagedBase); + } + std::fs::create_dir_all(&managed_base)?; + let managed_base = std::fs::canonicalize(managed_base)?; + if !managed_base.is_dir() { + return Err(SourceError::InvalidManagedBase); + } + Ok(Self { + managed_base, + next_id: 1, + sources: HashMap::new(), + }) + } + + pub fn open_local(&mut self, path: &str) -> Result { + let path = Path::new(path); + if !path.is_absolute() { + return Err(SourceError::InvalidLocalSource); + } + let root = std::fs::canonicalize(path).map_err(|_| SourceError::InvalidLocalSource)?; + if !root.is_dir() { + return Err(SourceError::InvalidLocalSource); + } + Ok(self.insert(root, None, None)) + } + + pub async fn acquire_git( + &mut self, + options: GitSourceOptions, + cancelled: Arc, + ) -> Result { + if options.url.is_empty() || options.deadline.is_zero() { + return Err(SourceError::GitFailed { + exit_code: None, + stderr: "invalid Git source request".to_string(), + }); + } + if cancelled.load(Ordering::Acquire) { + return Err(SourceError::Cancelled); + } + let id = self.allocate_id()?; + let managed_root = self.managed_base.join(format!( + "skill-deck-discovery-worker-{}-{id}", + std::process::id() + )); + if std::fs::symlink_metadata(&managed_root).is_ok() { + return Err(SourceError::InvalidManagedBase); + } + std::fs::create_dir(&managed_root)?; + std::fs::write(managed_root.join(".skill-deck-owner"), b"1\n")?; + let repository = managed_root.join("repo"); + let clone_result = run_git( + git_clone_arguments(&options, &repository), + options.proxy.as_deref(), + options.deadline, + Arc::clone(&cancelled), + ) + .await; + if let Err(error) = clone_result { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error); + } + let revision = run_git( + vec![ + "-C".to_string(), + repository.to_string_lossy().into_owned(), + "rev-parse".to_string(), + "--verify".to_string(), + "HEAD".to_string(), + ], + None, + options.deadline, + cancelled, + ) + .await + .and_then(|output| parse_revision(&output.stdout)); + let revision = match revision { + Ok(revision) => revision, + Err(error) => { + let _ = std::fs::remove_dir_all(&managed_root); + return Err(error); + } + }; + let opened = OpenedSource { + id, + root: repository.clone(), + revision: Some(revision.clone()), + }; + self.sources.insert( + id, + SourceRecord { + root: repository, + cleanup_root: Some(managed_root), + revision: Some(revision), + }, + ); + Ok(opened) + } + + pub fn root(&self, source_id: u64) -> Result<&Path, SourceError> { + self.sources + .get(&source_id) + .map(|source| source.root.as_path()) + .ok_or(SourceError::MissingSource) + } + + pub fn revision(&self, source_id: u64) -> Result, SourceError> { + self.sources + .get(&source_id) + .map(|source| source.revision.as_deref()) + .ok_or(SourceError::MissingSource) + } + + pub fn fingerprint(&self, source_id: u64, relative_path: &Path) -> Result { + let path = self.resolve(source_id, relative_path)?; + source_metadata_fingerprint(&path).map_err(|_| SourceError::InvalidLocalSource) + } + + pub async fn tree_revision( + &self, + source_id: u64, + relative_path: &Path, + deadline: Duration, + cancelled: Arc, + ) -> Result { + let root = self.root(source_id)?; + let relative = normalized_git_path(relative_path)?; + let revision_spec = if relative.is_empty() { + "HEAD^{tree}".to_string() + } else { + format!("HEAD:{relative}") + }; + let output = run_git( + vec![ + "-C".to_string(), + root.to_string_lossy().into_owned(), + "rev-parse".to_string(), + "--verify".to_string(), + revision_spec, + ], + None, + deadline, + cancelled, + ) + .await?; + parse_revision(&output.stdout) + } + + pub fn resolve(&self, source_id: u64, relative_path: &Path) -> Result { + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(SourceError::InvalidRelativePath); + } + Ok(self.root(source_id)?.join(relative_path)) + } + + pub fn release(&mut self, source_id: u64) -> Result<(), SourceError> { + let source = self + .sources + .remove(&source_id) + .ok_or(SourceError::MissingSource)?; + if let Some(cleanup_root) = source.cleanup_root { + std::fs::remove_dir_all(cleanup_root)?; + } + Ok(()) + } + + fn insert( + &mut self, + root: PathBuf, + cleanup_root: Option, + revision: Option, + ) -> OpenedSource { + let id = self.allocate_id().expect("source handle space exhausted"); + self.sources.insert( + id, + SourceRecord { + root: root.clone(), + cleanup_root, + revision: revision.clone(), + }, + ); + OpenedSource { id, root, revision } + } + + fn allocate_id(&mut self) -> Result { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .ok_or(SourceError::InvalidManagedBase)?; + Ok(id) + } +} + +pub async fn probe_git( + options: GitSourceOptions, + cancelled: Arc, +) -> Result { + if options.url.is_empty() || options.deadline.is_zero() { + return Err(SourceError::GitFailed { + exit_code: None, + stderr: "invalid Git probe request".to_string(), + }); + } + let output = run_git( + vec![ + "ls-remote".to_string(), + "--exit-code".to_string(), + "--".to_string(), + options.url, + "HEAD".to_string(), + ], + options.proxy.as_deref(), + options.deadline, + cancelled, + ) + .await?; + let revision = output + .stdout + .split(|byte| byte.is_ascii_whitespace()) + .next() + .unwrap_or_default(); + parse_revision(revision) +} + +impl Drop for SourceManager { + fn drop(&mut self) { + for source in self.sources.values_mut() { + if let Some(cleanup_root) = source.cleanup_root.take() { + let _ = std::fs::remove_dir_all(cleanup_root); + } + } + } +} + +struct GitOutput { + stdout: Vec, +} + +fn git_clone_arguments(options: &GitSourceOptions, repository: &Path) -> Vec { + let mut arguments = vec![ + "clone".to_string(), + "--depth".to_string(), + "1".to_string(), + "--progress".to_string(), + ]; + if let Some(git_ref) = &options.git_ref { + arguments.extend(["--branch".to_string(), git_ref.clone()]); + } + arguments.extend([ + "--".to_string(), + options.url.clone(), + repository.to_string_lossy().into_owned(), + ]); + arguments +} + +fn normalized_git_path(path: &Path) -> Result { + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir)) + { + return Err(SourceError::InvalidRelativePath); + } + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(value) => { + components.push(value.to_str().ok_or(SourceError::InvalidRelativePath)?) + } + Component::CurDir => {} + _ => return Err(SourceError::InvalidRelativePath), + } + } + Ok(components.join("/")) +} + +#[allow( + clippy::disallowed_methods, + reason = "Worker crate 独立管理 Linux Git 进程组,不依赖 app crate 的进程 helper" +)] +async fn run_git( + arguments: Vec, + proxy: Option<&str>, + deadline: Duration, + cancelled: Arc, +) -> Result { + let mut command = Command::new("git"); + command + .env("LC_ALL", "C") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "") + .env("SSH_ASKPASS", "") + .env("GIT_ALLOW_PROTOCOL", "https:http:ssh:git:file") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(proxy) = proxy { + command.args(["-c", &format!("http.proxy={proxy}")]); + } + command.args(arguments); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + let mut child = command + .spawn() + .map_err(|error| SourceError::GitUnavailable { + message: error.to_string(), + })?; + let process_group_id = child.id().and_then(|id| i32::try_from(id).ok()); + let stdout = child.stdout.take().ok_or_else(|| SourceError::GitFailed { + exit_code: None, + stderr: "Git stdout is unavailable".to_string(), + })?; + let stderr = child.stderr.take().ok_or_else(|| SourceError::GitFailed { + exit_code: None, + stderr: "Git stderr is unavailable".to_string(), + })?; + let stdout_task = tokio::spawn(read_prefix(stdout, GIT_STDOUT_LIMIT)); + let stderr_task = tokio::spawn(read_tail(stderr, GIT_STDERR_LIMIT)); + let mut wait = Box::pin(child.wait()); + let mut deadline_sleep = Box::pin(tokio::time::sleep(deadline)); + let status = loop { + tokio::select! { + result = &mut wait => break result?, + _ = &mut deadline_sleep => { + kill_process_group(process_group_id); + let _ = wait.await; + return Err(SourceError::DeadlineExceeded); + } + _ = tokio::time::sleep(Duration::from_millis(25)) => { + if cancelled.load(Ordering::Acquire) { + kill_process_group(process_group_id); + let _ = wait.await; + return Err(SourceError::Cancelled); + } + } + } + }; + let stdout = stdout_task.await.map_err(|_| SourceError::GitFailed { + exit_code: status.code(), + stderr: "failed to capture Git stdout".to_string(), + })??; + let stderr = stderr_task.await.map_err(|_| SourceError::GitFailed { + exit_code: status.code(), + stderr: "failed to capture Git stderr".to_string(), + })??; + if !status.success() { + return Err(SourceError::GitFailed { + exit_code: status.code(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }); + } + Ok(GitOutput { stdout }) +} + +async fn read_prefix(mut reader: R, limit: usize) -> Result, std::io::Error> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + return Ok(output); + } + let remaining = limit.saturating_sub(output.len()); + output.extend_from_slice(&buffer[..read.min(remaining)]); + } +} + +async fn read_tail(mut reader: R, limit: usize) -> Result, std::io::Error> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + return Ok(output); + } + output.extend_from_slice(&buffer[..read]); + if output.len() > limit { + output.drain(..output.len() - limit); + } + } +} + +fn parse_revision(bytes: &[u8]) -> Result { + let revision = std::str::from_utf8(bytes) + .map_err(|_| SourceError::GitFailed { + exit_code: None, + stderr: "Git revision is not UTF-8".to_string(), + })? + .trim(); + if matches!(revision.len(), 40 | 64) && revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(revision.to_ascii_lowercase()) + } else { + Err(SourceError::GitFailed { + exit_code: None, + stderr: "Git returned an invalid revision".to_string(), + }) + } +} + +#[cfg(unix)] +fn kill_process_group(process_group_id: Option) { + if let Some(process_group_id) = process_group_id { + let _ = unsafe { libc::kill(-process_group_id, libc::SIGKILL) }; + } +} + +#[cfg(not(unix))] +fn kill_process_group(_process_group_id: Option) {} diff --git a/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs b/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs new file mode 100644 index 00000000..a7455941 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/inbound_transfer.rs @@ -0,0 +1,38 @@ +use sha2::{Digest, Sha256}; +use wsl_environment_worker::inbound_transfer::{ + InboundTransfer, TransferCompletion, TransferDeclaration, +}; + +#[tokio::test] +async fn transfer_streams_to_disk_and_verifies_its_completion() { + let temp = tempfile::NamedTempFile::new().unwrap(); + let file = tokio::fs::File::from_std(temp.reopen().unwrap()); + let payload = b"payload-bytes"; + let sha256 = format!("sha256:{:x}", Sha256::digest(payload)); + let mut transfer = InboundTransfer::begin( + TransferDeclaration { + owner_request_id: 7, + transfer_id: 11, + total_bytes: payload.len() as u64, + sha256: sha256.clone(), + }, + 1024, + file, + ) + .unwrap(); + + transfer.write_chunk(11, &payload[..4]).await.unwrap(); + transfer.write_chunk(11, &payload[4..]).await.unwrap(); + let completed = transfer + .complete(TransferCompletion { + owner_request_id: 7, + transfer_id: 11, + total_bytes: payload.len() as u64, + sha256, + }) + .await + .unwrap(); + drop(completed.file); + + assert_eq!(std::fs::read(temp.path()).unwrap(), payload); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/library.rs b/src-tauri/crates/wsl-environment-worker/tests/library.rs new file mode 100644 index 00000000..98aab1c0 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/library.rs @@ -0,0 +1,108 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::fingerprint_path; +use environment_engine::projection::{project_targets, ProjectionRequest}; +use environment_protocol::LibraryMemberAction; +use environment_protocol::{LibraryOperationAction, LibraryOperationRequest}; +use wsl_environment_worker::library::LibraryManager; +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn manager_saves_and_reads_the_catalog_under_its_home() { + let home = tempfile::tempdir().unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let bytes = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + + let revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: bytes.clone(), + action: LibraryOperationAction::SaveCatalog { + library_ids: Vec::new(), + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let catalog = manager.read_catalog().unwrap(); + + assert_eq!(catalog.bytes, bytes); + assert!(catalog.present); + assert_eq!(catalog.revision, Some(revision)); + assert!(home + .path() + .join(".skill-deck/skill-libraries/catalog.json") + .is_file()); +} + +#[test] +fn manager_commits_a_member_from_an_existing_payload_handle() { + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"member").unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let mut payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let payload = payloads + .acquire_from_source("library", "payload-demo", &source) + .unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":[]}]}"#.to_vec(); + let catalog_revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: first, + action: LibraryOperationAction::SaveCatalog { + library_ids: vec!["lib-1".to_string()], + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let destination = home + .path() + .join(".skill-deck/skill-libraries/libraries/lib-1/skills/demo"); + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1","skills":["demo"]}]}"#.to_vec(); + + manager + .execute( + LibraryOperationRequest { + operation_id: "member-1".to_string(), + expected_catalog_revision: Some(catalog_revision), + catalog_bytes: second.clone(), + action: LibraryOperationAction::CommitMember { + library_id: "lib-1".to_string(), + skill_name: "demo".to_string(), + expected_anchor_device: target.anchor_device, + expected_anchor_inode: target.anchor_inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + mutation: LibraryMemberAction::Upsert { + payload_id: payload.id, + }, + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + + assert_eq!( + std::fs::read(destination.join("SKILL.md")).unwrap(), + b"member" + ); + assert_eq!(manager.read_catalog().unwrap().bytes, second); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs b/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs new file mode 100644 index 00000000..e43f032d --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/mutation_recovery.rs @@ -0,0 +1,96 @@ +#![cfg(target_os = "linux")] + +use wsl_environment_worker::mutation::{MutationRecoveryError, MutationRecoveryStore}; + +#[test] +fn recovery_store_lists_old_markers_and_cleans_only_an_exact_marker() { + let temp = tempfile::tempdir().unwrap(); + let store = MutationRecoveryStore::new(temp.path().to_path_buf()).unwrap(); + let resource_id = "a".repeat(64); + let root = temp + .path() + .join(format!("skill-deck-operation-{resource_id}")); + let backup = temp + .path() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let stage = temp + .path() + .join(format!(".skill-deck-stage-{resource_id}-000000")); + std::fs::create_dir(&root).unwrap(); + std::fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + ) + .unwrap(); + let destination = temp.path().join("demo"); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "kind": "cleanupOnly", + "entries": [{ + "destination": { "nativePath": destination }, + "backup": { "nativePath": backup } + }] + })) + .unwrap(); + std::fs::write(root.join("recovery.json"), &marker).unwrap(); + std::fs::create_dir(&backup).unwrap(); + std::fs::create_dir(&stage).unwrap(); + + let listed = store.list().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].resource_id, resource_id); + assert_eq!(listed[0].marker_bytes.as_deref(), Some(marker.as_slice())); + + assert_eq!( + store + .cleanup(&resource_id, b"different", std::slice::from_ref(&backup)) + .unwrap_err(), + MutationRecoveryError::StaleMarker + ); + assert!(root.is_dir()); + assert!(backup.is_dir()); + assert!(stage.is_dir()); + + store + .cleanup(&resource_id, &marker, std::slice::from_ref(&backup)) + .unwrap(); + assert!(!root.exists()); + assert!(!backup.exists()); + assert!(!stage.exists()); +} + +#[test] +fn recovery_store_rejects_a_backup_outside_its_target_parent() { + let temp = tempfile::tempdir().unwrap(); + let store = MutationRecoveryStore::new(temp.path().to_path_buf()).unwrap(); + let resource_id = "b".repeat(64); + let root = temp + .path() + .join(format!("skill-deck-operation-{resource_id}")); + let destination = temp.path().join("targets/demo"); + let backup = temp + .path() + .join(format!("elsewhere/.skill-deck-backup-{resource_id}-000000")); + std::fs::create_dir(&root).unwrap(); + std::fs::write( + root.join(".skill-deck-owner"), + format!("1\n{resource_id}\n"), + ) + .unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "entries": [{ + "destination": { "nativePath": destination }, + "backup": { "nativePath": backup } + }] + })) + .unwrap(); + std::fs::write(root.join("recovery.json"), &marker).unwrap(); + + assert_eq!( + store + .cleanup(&resource_id, &marker, std::slice::from_ref(&backup)) + .unwrap_err(), + MutationRecoveryError::UnsafeRoot + ); + assert!(root.is_dir()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs b/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs new file mode 100644 index 00000000..7e1a0e5b --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/mutation_transaction.rs @@ -0,0 +1,374 @@ +#![cfg(target_os = "linux")] + +use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, MutationError, +}; +use environment_protocol::{ + MutationEntry, MutationEntryAction, MutationLock, MutationLockEntry, MutationLockSchema, + MutationUnitOutcome, MutationUnitRequest, +}; +use wsl_environment_worker::mutation::MutationManager; +use wsl_environment_worker::mutation::WorkerMutationError; +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn accepted_transaction_commits_directory_and_lock_before_exact_ack_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let mut payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let payload = payloads + .acquire_from_source("mutation", "payload-demo", &source) + .unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"old"}}}"#, + ) + .unwrap(); + let resource_id = "b".repeat(64); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let marker = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-1", + "unitId": "unit-1", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "present", + "originalFingerprint": fingerprint_path(&destination).unwrap(), + "phase": "staged" + }] + })) + .unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-1".to_string(), + unit_id: "unit-1".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: parent_identity(destination.parent().unwrap()) + .unwrap() + .device, + expected_anchor_inode: parent_identity(destination.parent().unwrap()) + .unwrap() + .inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + action: MutationEntryAction::Materialize { + payload_id: payload.id, + }, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Replace { + key: "demo".to_string(), + replacement_json: br#"{"source":"new"}"#.to_vec(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(temp + .path() + .join(format!("skill-deck-operation-{resource_id}/recovery.json")) + .is_file()); + + let outcome = manager.execute(accepted, || false).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected transaction outcome: {outcome:?}"), + }; + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!(lock["skills"]["demo"]["source"], "new"); + assert!(backup.is_dir()); + + manager.acknowledge(&cleanup).unwrap(); + assert!(!backup.exists()); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} + +#[test] +fn lock_conflict_restores_the_directory_and_keeps_typed_conflict_evidence() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"changed"}}}"#, + ) + .unwrap(); + let resource_id = "c".repeat(64); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-2", + "unitId": "unit-2", + "subject": { + "operationKind": "remove", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "missing", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-2".to_string(), + unit_id: "unit-2".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: None, + action: MutationEntryAction::Remove, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Remove { + key: "demo".to_string(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"expected"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + let outcome = manager.execute(accepted, || false).unwrap(); + + assert!(matches!( + outcome, + MutationUnitOutcome::Failed { + ref code, + ref parameters, + .. + } if code == "lockConflictSkill" + && parameters == &vec![("skillName".to_string(), "demo".to_string())] + )); + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"old"); + assert!(!backup.exists()); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} + +#[test] +fn authoritative_accept_preserves_stale_target_classification() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let expected = fingerprint_path(&destination).unwrap(); + let expected_content = content_hash_path(&destination).unwrap(); + std::fs::write(destination.join("changed"), b"changed").unwrap(); + let request = MutationUnitRequest { + resource_id: "d".repeat(64), + operation_id: "operation-3".to_string(), + unit_id: "unit-3".to_string(), + initial_marker_json: Vec::new(), + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: expected, + expected_content_hash: Some(expected_content), + action: MutationEntryAction::Keep, + }], + lock: None, + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + assert!(matches!( + manager.accept(request, &payloads, || false), + Err(WorkerMutationError::Engine(MutationError::StaleTarget)) + )); +} + +#[test] +fn lock_only_transaction_is_accepted_and_keeps_recovery_evidence_until_ack() { + let temp = tempfile::tempdir().unwrap(); + let payloads = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let destination = temp.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"same").unwrap(); + let lock_path = temp.path().join("skills-lock.json"); + std::fs::write( + &lock_path, + br#"{"version":3,"skills":{"demo":{"source":"old"}}}"#, + ) + .unwrap(); + let resource_id = "e".repeat(64); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "operation-4", + "unitId": "unit-4", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "target-1", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": null, + "expectedState": "present", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "operation-4".to_string(), + unit_id: "unit-4".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: MutationEntryAction::Keep, + }], + lock: Some(MutationLock { + target: lock_path.to_string_lossy().into_owned(), + legacy_target: None, + schema: MutationLockSchema::Global, + entry: MutationLockEntry::Replace { + key: "demo".to_string(), + replacement_json: br#"{"source":"new"}"#.to_vec(), + }, + root_replacements_json: Default::default(), + expected_entries_json: std::collections::BTreeMap::from([( + "demo".to_string(), + Some(br#"{"source":"old"}"#.to_vec()), + )]), + expected_roots_json: Default::default(), + }), + deadline_millis: 60_000, + }; + let manager = MutationManager::new(temp.path().to_path_buf()).unwrap(); + + let accepted = manager.accept(request, &payloads, || false).unwrap(); + assert!(MutationManager::requires_acceptance(&accepted)); + let outcome = manager.execute(accepted, || false).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected transaction outcome: {outcome:?}"), + }; + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!(lock["skills"]["demo"]["source"], "new"); + assert!(temp + .path() + .join(format!("skill-deck-operation-{resource_id}/recovery.json")) + .is_file()); + + manager.acknowledge(&cleanup).unwrap(); + assert!(!temp + .path() + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/payload.rs b/src-tauri/crates/wsl-environment-worker/tests/payload.rs new file mode 100644 index 00000000..db9df73b --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/payload.rs @@ -0,0 +1,103 @@ +#![cfg(target_os = "linux")] + +use std::io::Read; + +use wsl_environment_worker::payload::PayloadManager; + +#[test] +fn payload_manager_builds_verifies_reads_and_removes_worker_owned_payloads() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir_all(source.join("scripts")).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + std::fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + + let acquired = manager + .acquire_from_source("session-1", "payload-demo", &source) + .unwrap(); + assert!(acquired.root.join("manifest.json").is_file()); + let verified = manager + .verify("session-1", "payload-demo") + .unwrap() + .unwrap(); + assert_ne!(verified.id, acquired.id); + assert_eq!(verified.manifest, acquired.manifest); + + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.as_deref()) + .unwrap(); + let mut blob = manager.read_blob(acquired.id, blob_id).unwrap().unwrap(); + let mut content = Vec::new(); + blob.read_to_end(&mut content).unwrap(); + assert!(content == b"demo" || content == b"#!/bin/sh\n"); + + let root = acquired.root; + manager.remove("session-1", "payload-demo").unwrap(); + assert!(!root.exists()); + assert!(manager.read_blob(acquired.id, blob_id).is_err()); +} + +#[test] +fn payload_manager_only_sweeps_valid_unprotected_owned_sessions() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + manager + .acquire_from_source("protected", "payload-demo", &source) + .unwrap(); + manager + .acquire_from_source("orphan", "payload-demo", &source) + .unwrap(); + std::fs::create_dir(temp.path().join("skill-deck-source-foreign")).unwrap(); + + let report = manager.sweep_orphans(&["protected".to_string()]).unwrap(); + + assert_eq!(report.removed_sessions, 1); + assert_eq!(report.protected_sessions, 1); + assert!(report.cleanup_blocked); + assert!(temp.path().join("skill-deck-source-protected").is_dir()); + assert!(!temp.path().join("skill-deck-source-orphan").exists()); + assert!(temp.path().join("skill-deck-source-foreign").is_dir()); +} + +#[test] +fn payload_upload_is_staged_and_published_only_after_exact_manifest_validation() { + use sha2::{Digest, Sha256}; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let mut manager = PayloadManager::new(temp.path().to_path_buf()).unwrap(); + let source_payload = manager + .acquire_from_source("source", "payload-demo", &source) + .unwrap(); + let manifest = source_payload.manifest.clone(); + manager.remove_session("source").unwrap(); + + let upload_id = manager.begin_upload("uploaded", "payload-demo").unwrap(); + let blob_id = format!("{:x}", Sha256::digest(b"demo")); + let prepared = manager.prepare_blob(upload_id, &blob_id).unwrap(); + std::fs::write(&prepared.path, b"demo").unwrap(); + manager + .commit_blob(upload_id, &blob_id, prepared.path) + .unwrap(); + let manifest_file = manager.prepare_manifest(upload_id).unwrap(); + std::fs::write(&manifest_file.path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let uploaded = manager + .finalize_upload(upload_id, manifest_file.path) + .unwrap(); + + assert_eq!(uploaded.manifest, manifest); + assert!(uploaded.root.join("manifest.json").is_file()); + + let abandoned = manager.begin_upload("abandoned", "payload-demo").unwrap(); + manager.remove_session("abandoned").unwrap(); + assert!(manager.prepare_manifest(abandoned).is_err()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/runtime.rs b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs new file mode 100644 index 00000000..12cf28be --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs @@ -0,0 +1,135 @@ +use std::io::Write; + +#[cfg(target_os = "linux")] +use environment_protocol::{InspectionEntryKind, InspectionRequest, InspectionRoot}; +use environment_protocol::{Message, PathKind}; +#[cfg(target_os = "linux")] +use wsl_environment_worker::execute_inspection; +use wsl_environment_worker::{file_sha256, Dispatch, WorkerIdentity, WorkerRuntime}; + +fn identity(home: &str) -> WorkerIdentity { + WorkerIdentity { + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: home.to_string(), + } +} + +#[test] +fn handshake_accepts_only_the_running_binary_build() { + let temp = tempfile::tempdir().unwrap(); + let runtime = WorkerRuntime::new( + "sha256:worker-v1".to_string(), + identity(temp.path().to_str().unwrap()), + ); + + assert_eq!( + runtime.dispatch(Message::Handshake { + build_id: "sha256:worker-v1".to_string(), + }), + Dispatch { + response: Some(Message::HandshakeResult { + build_id: "sha256:worker-v1".to_string(), + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: temp.path().to_string_lossy().into_owned(), + }), + close: false, + } + ); + + assert_eq!( + runtime.dispatch(Message::Handshake { + build_id: "sha256:other".to_string(), + }), + Dispatch { + response: Some(Message::Error { + code: "buildMismatch".to_string(), + phase: "handshake".to_string(), + parameters: Vec::new(), + }), + close: true, + } + ); +} + +#[test] +fn observe_path_reports_directory_file_missing_and_symlink() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("file"); + std::fs::write(&file, b"content").unwrap(); + let runtime = WorkerRuntime::new("build".to_string(), identity(temp.path().to_str().unwrap())); + + for (path, expected) in [ + (temp.path().to_path_buf(), PathKind::Directory), + (file, PathKind::File), + (temp.path().join("missing"), PathKind::Missing), + ] { + assert_eq!( + runtime.dispatch(Message::ObservePath { + path: path.to_string_lossy().into_owned(), + }), + Dispatch { + response: Some(Message::PathObserved { kind: expected }), + close: false, + } + ); + } + + #[cfg(unix)] + { + let link = temp.path().join("link"); + std::os::unix::fs::symlink(temp.path(), &link).unwrap(); + assert_eq!( + runtime.dispatch(Message::ObservePath { + path: link.to_string_lossy().into_owned(), + }), + Dispatch { + response: Some(Message::PathObserved { + kind: PathKind::SymlinkDirectory, + }), + close: false, + } + ); + } +} + +#[test] +fn self_hash_is_the_sha256_of_the_exact_binary_bytes() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(b"worker-bytes").unwrap(); + assert_eq!( + file_sha256(file.path()).unwrap(), + "sha256:11b87fcc63c88aff5a8568038519a02d7db3551e1432836b8d60bbf6eb6a7b38" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn inspection_adapter_executes_the_shared_engine_and_returns_raw_path_bytes() { + use std::os::unix::ffi::OsStringExt; + + let temp = tempfile::tempdir().unwrap(); + let raw_name = std::ffi::OsString::from_vec(vec![b's', b'k', 0x80]); + std::fs::write(temp.path().join(&raw_name), b"payload").unwrap(); + + let response = execute_inspection( + InspectionRequest { + roots: vec![InspectionRoot { + path: temp.path().to_string_lossy().into_owned(), + stat_only: false, + }], + per_file_limit: 16, + aggregate_limit: 16, + deadline_millis: 1_000, + }, + || false, + ) + .unwrap(); + + assert!(response.facts.iter().any(|fact| { + fact.relative_path == vec![b's', b'k', 0x80] && fact.kind == InspectionEntryKind::File + })); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/source.rs b/src-tauri/crates/wsl-environment-worker/tests/source.rs new file mode 100644 index 00000000..56dbef61 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/source.rs @@ -0,0 +1,148 @@ +#![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_methods, + reason = "Worker Git 集成测试需要直接创建受控的本地 Git fixture" +)] + +use std::process::Command; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use environment_protocol::{SourceScanMode, SourceScanRequest, SourceScanRoot}; +use wsl_environment_worker::source::scan_source; +use wsl_environment_worker::source::{GitSourceOptions, SourceManager}; + +fn git(cwd: &std::path::Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +#[tokio::test] +async fn source_manager_owns_git_clones_but_not_opened_local_directories() { + let temp = tempfile::tempdir().unwrap(); + let repository = temp.path().join("repository"); + std::fs::create_dir(&repository).unwrap(); + git(&repository, &["init", "-b", "main"]); + git(&repository, &["config", "user.email", "test@example.com"]); + git(&repository, &["config", "user.name", "Skill Deck Test"]); + std::fs::write(repository.join("SKILL.md"), b"demo").unwrap(); + std::fs::create_dir_all(repository.join("skills/demo")).unwrap(); + std::fs::write(repository.join("skills/demo/SKILL.md"), b"nested").unwrap(); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "fixture"]); + let expected_revision = git(&repository, &["rev-parse", "HEAD"]); + let expected_skill_revision = git(&repository, &["rev-parse", "HEAD:skills/demo"]); + + let managed_base = temp.path().join("managed"); + std::fs::create_dir(&managed_base).unwrap(); + let mut manager = SourceManager::new(managed_base).unwrap(); + let local = manager.open_local(repository.to_str().unwrap()).unwrap(); + let git_source = manager + .acquire_git( + GitSourceOptions { + url: repository.to_string_lossy().into_owned(), + git_ref: None, + proxy: None, + deadline: Duration::from_secs(30), + }, + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + + assert_eq!( + git_source.revision.as_deref(), + Some(expected_revision.as_str()) + ); + let tree_revision = manager + .tree_revision( + git_source.id, + std::path::Path::new(""), + Duration::from_secs(30), + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + assert_eq!(tree_revision.len(), expected_revision.len()); + let skill_revision = manager + .tree_revision( + git_source.id, + std::path::Path::new("skills/demo"), + Duration::from_secs(30), + Arc::new(AtomicBool::new(false)), + ) + .await + .unwrap(); + assert_eq!(skill_revision, expected_skill_revision); + assert!(manager + .root(git_source.id) + .unwrap() + .join("SKILL.md") + .is_file()); + let managed_root = manager.root(git_source.id).unwrap().to_path_buf(); + manager.release(git_source.id).unwrap(); + assert!(!managed_root.exists()); + manager.release(local.id).unwrap(); + assert!(repository.join("SKILL.md").is_file()); +} + +#[test] +fn source_relative_paths_cannot_escape_the_opened_root() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("source"); + std::fs::create_dir(&root).unwrap(); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + let source = manager.open_local(root.to_str().unwrap()).unwrap(); + + assert!(manager + .resolve(source.id, std::path::Path::new("skills/demo")) + .is_ok()); + assert!(manager + .resolve(source.id, std::path::Path::new("../outside")) + .is_err()); + assert!(manager + .resolve(source.id, std::path::Path::new("/absolute")) + .is_err()); +} + +#[test] +fn source_scan_projects_relative_wire_paths_through_the_handle() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("source"); + std::fs::create_dir_all(root.join("skills/demo")).unwrap(); + std::fs::write(root.join("skills/demo/SKILL.md"), b"demo").unwrap(); + let mut manager = SourceManager::new(temp.path().join("managed")).unwrap(); + let source = manager.open_local(root.to_str().unwrap()).unwrap(); + + let response = scan_source( + &manager, + SourceScanRequest { + source_id: source.id, + roots: vec![SourceScanRoot { + relative_path: b"skills".to_vec(), + stat_only: false, + }], + mode: SourceScanMode::Recursive, + per_file_limit: 1024, + aggregate_limit: 4096, + deadline_millis: 30_000, + }, + || false, + ) + .unwrap(); + + assert!(response + .entries + .iter() + .any(|entry| entry.relative_path == b"demo/SKILL.md")); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs new file mode 100644 index 00000000..c48208a1 --- /dev/null +++ b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs @@ -0,0 +1,1161 @@ +#![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_methods, + reason = "Worker 集成测试需要直接启动当前 crate 构建的受控二进制" +)] + +use std::process::Stdio; +use std::time::Duration; + +use environment_protocol::{codec, decode, Envelope, Message, PathKind, WireRecord}; +#[cfg(target_os = "linux")] +use environment_protocol::{ + decode_inspection_response, decode_payload, AcquirePayloadFromSourceRequest, + DocumentWritePreparation, InspectionRequest, InspectionRoot, MapHostPathsRequest, + MapHostPathsResponse, MutationEntry, MutationEntryAction, MutationUnitOutcome, + MutationUnitRequest, OpenLocalSourceRequest, PayloadReadyResponse, VerifyPayloadRequest, +}; +use futures_util::StreamExt; +use tokio::process::Command; +use tokio::time::timeout; +use tokio_util::codec::FramedRead; +use wsl_environment_worker::file_sha256; + +#[tokio::test] +async fn worker_binary_handshakes_observes_home_and_shuts_down() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { + build_id: build_id.clone(), + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await, + Envelope { + request_id: 1, + message: Message::HandshakeResult { + build_id: actual, + distro, + user, + home: actual_home, + .. + }, + } if actual == build_id + && distro == "Ubuntu" + && user == "alice" + && actual_home == home.path().to_string_lossy() + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::ObservePath { + path: home.path().to_string_lossy().into_owned(), + }, + })) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 2, + message: Message::PathObserved { + kind: PathKind::Directory, + }, + } + ); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn worker_maps_host_paths_with_structured_wslpath_arguments() { + use std::os::unix::fs::PermissionsExt; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let wslpath = tools.path().join("wslpath"); + std::fs::write( + &wslpath, + r#"#!/bin/sh +[ "$1" = "-u" ] && [ "$2" = "--" ] || exit 64 +case "$3" in + 'C:\Code\Skill Deck') printf '%s\n' '/mnt/c/Code/Skill Deck' ;; + '\\server\share\项目') printf '%s\n' '/mnt/server/share/项目' ;; + *) exit 65 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&wslpath, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .env("PATH", tools.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![ + r"C:\Code\Skill Deck".to_string(), + r"\\server\share\项目".to_string(), + ], + deadline_millis: 5_000, + }, + }, + })) + .await + .unwrap(); + let response: MapHostPathsResponse = + decode_payload(&next_transfer(&mut reader, 2).await).unwrap(); + assert_eq!( + response.mapped, + ["/mnt/c/Code/Skill Deck", "/mnt/server/share/项目"] + ); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[tokio::test] +async fn host_path_mapping_deadline_reaps_wslpath() { + use std::os::unix::fs::PermissionsExt; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let pid_file = tools.path().join("wslpath.pid"); + let wslpath = tools.path().join("wslpath"); + std::fs::write( + &wslpath, + "#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$SKILL_DECK_TEST_PID\"\nexec /bin/sleep 30\n", + ) + .unwrap(); + std::fs::set_permissions(&wslpath, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .env("PATH", tools.path()) + .env("SKILL_DECK_TEST_PID", &pid_file) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::MapHostPaths { + request: MapHostPathsRequest { + paths: vec![r"C:\Code\slow".to_string()], + deadline_millis: 50, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await, + Envelope { + request_id: 2, + message: Message::Error { ref code, ref phase, .. }, + } if code == "deadlineExceeded" && phase == "pathMapping" + )); + + let pid = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse::() + .unwrap(); + for _ in 0..100 { + if !std::path::Path::new(&format!("/proc/{pid}")).exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_streams_inspection_without_starving_control() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("skills"); + for index in 0..128 { + let skill = root.join(format!("skill-{index}")); + std::fs::create_dir_all(&skill).unwrap(); + std::fs::write(skill.join("SKILL.md"), vec![b'x'; 32 * 1024]).unwrap(); + } + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { + build_id: build_id.clone(), + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::InspectFilesystem { + request: InspectionRequest { + roots: vec![InspectionRoot { + path: root.to_string_lossy().into_owned(), + stat_only: false, + }], + per_file_limit: 256 * 1024, + aggregate_limit: 8 * 1024 * 1024, + deadline_millis: 5_000, + }, + }, + })) + .await + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::ObservePath { + path: home.path().to_string_lossy().into_owned(), + }, + })) + .await + .unwrap(); + + let mut payload = Vec::new(); + let mut record_index = 0usize; + let mut observed_index = None; + let mut completed_index = None; + while observed_index.is_none() || completed_index.is_none() { + let record = decode(&reader.next().await.unwrap().unwrap()).unwrap(); + match record { + WireRecord::Control(Envelope { + request_id: 3, + message: Message::PathObserved { kind }, + }) => { + assert_eq!(kind, PathKind::Directory); + observed_index = Some(record_index); + } + WireRecord::Control(Envelope { + request_id: 2, + message: Message::BeginTransfer { .. }, + }) => {} + WireRecord::PayloadChunk { + transfer_id: 2, + bytes, + } => payload.extend_from_slice(&bytes), + WireRecord::Control(Envelope { + request_id: 2, + message: Message::TransferCompleted { .. }, + }) => completed_index = Some(record_index), + other => panic!("unexpected worker record: {other:?}"), + } + record_index += 1; + } + + assert!(observed_index.unwrap() < completed_index.unwrap()); + let response = decode_inspection_response(&payload).unwrap(); + assert_eq!(response.total_content_bytes, 4 * 1024 * 1024); + assert_eq!(response.facts.len(), 257); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_completes_source_payload_and_host_upload_lifecycles_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"demo").unwrap(); + let session_id = format!("stdio-{}", std::process::id()); + let uploaded_session_id = format!("stdio-upload-{}", std::process::id()); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: source.to_string_lossy().into_owned(), + }, + }, + })) + .await + .unwrap(); + let source_id = match next_message(&mut reader).await.message { + Message::SourceOpened { source_id, .. } => source_id, + message => panic!("unexpected source response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id, + relative_path: Vec::new(), + session_id: session_id.clone(), + payload_name: "payload-stdio".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let acquired: PayloadReadyResponse = + decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.clone()) + .unwrap(); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::ReadPayloadBlob { + payload_id: acquired.payload_id, + blob_id: blob_id.clone(), + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + assert_eq!(next_transfer(&mut reader, 4).await, b"demo"); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::BeginPayloadUpload { + session_id: uploaded_session_id.clone(), + payload_name: "payload-stdio".to_string(), + }, + })) + .await + .unwrap(); + let upload_id = match next_message(&mut reader).await.message { + Message::PayloadUploadBegun { upload_id } => upload_id, + message => panic!("unexpected upload response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::UploadPayloadBlob { + upload_id, + blob_id: blob_id.clone(), + total_bytes: 4, + sha256: format!("sha256:{blob_id}"), + }, + })) + .await + .unwrap(); + let blob_transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected transfer response: {message:?}"), + }; + writer + .send_transfer(7, blob_transfer_id, b"demo") + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadBlobUploaded { .. } + )); + let manifest = serde_json::to_vec(&acquired.manifest).unwrap(); + let manifest_sha = format!("sha256:{:x}", Sha256::digest(&manifest)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, + message: Message::FinalizePayloadUpload { + upload_id, + total_bytes: manifest.len() as u64, + sha256: manifest_sha, + }, + })) + .await + .unwrap(); + let manifest_transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected manifest response: {message:?}"), + }; + writer + .send_transfer(9, manifest_transfer_id, &manifest) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadUploadFinalized { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 10, + message: Message::VerifyPayload { + request: VerifyPayloadRequest { + session_id: uploaded_session_id.clone(), + payload_name: "payload-stdio".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let verified: Option = + decode_payload(&next_transfer(&mut reader, 10).await).unwrap(); + assert_eq!(verified.unwrap().manifest, acquired.manifest); + + for (request_id, cleanup_session) in [(11, session_id), (12, uploaded_session_id)] { + writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::RemovePayloadSession { + session_id: cleanup_session, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadSessionRemoved { .. } + )); + } + writer + .send_control(WireRecord::Control(Envelope { + request_id: 13, + message: Message::ReleaseSource { source_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::SourceReleased { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 14, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_executes_one_accepted_mutation_and_acknowledges_cleanup_over_stdio() { + use environment_engine::linux_mutation::{ + content_hash_path, fingerprint_path, parent_identity, + }; + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let source = home.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("SKILL.md"), b"new").unwrap(); + let destination = home.path().join("targets/demo"); + std::fs::create_dir_all(&destination).unwrap(); + std::fs::write(destination.join("SKILL.md"), b"old").unwrap(); + let resource_id = format!( + "{:x}", + Sha256::digest(destination.as_os_str().as_encoded_bytes()) + ); + let backup = destination + .parent() + .unwrap() + .join(format!(".skill-deck-backup-{resource_id}-000000")); + let session_id = format!("stdio-mutation-{}", std::process::id()); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::OpenLocalSource { + request: OpenLocalSourceRequest { + path: source.to_string_lossy().into_owned(), + }, + }, + })) + .await + .unwrap(); + let source_id = match next_message(&mut reader).await.message { + Message::SourceOpened { source_id, .. } => source_id, + message => panic!("unexpected source response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::AcquirePayloadFromSource { + request: AcquirePayloadFromSourceRequest { + source_id, + relative_path: Vec::new(), + session_id: session_id.clone(), + payload_name: "payload-mutation".to_string(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let payload: PayloadReadyResponse = + decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + let anchor = parent_identity(destination.parent().unwrap()).unwrap(); + let fingerprint = fingerprint_path(&destination).unwrap(); + let marker = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "resourceId": resource_id, + "kind": "inProgress", + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "operationId": "stdio-operation", + "unitId": "stdio-unit", + "subject": { + "operationKind": "install", + "skillName": "demo", + "context": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "scope": { "kind": "global" } + } + }, + "createdAtEpochMs": 1, + "entries": [{ + "physicalTargetDigest": "stdio-target", + "destination": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": destination + }, + "backup": { + "environment": { "kind": "wsl", "distroName": "Ubuntu" }, + "nativePath": backup + }, + "expectedState": "present", + "originalFingerprint": fingerprint, + "phase": "staged" + }] + })) + .unwrap(); + let request = MutationUnitRequest { + resource_id: resource_id.clone(), + operation_id: "stdio-operation".to_string(), + unit_id: "stdio-unit".to_string(), + initial_marker_json: marker, + entries: vec![MutationEntry { + destination: destination.to_string_lossy().into_owned(), + expected_anchor_device: anchor.device, + expected_anchor_inode: anchor.inode, + expected_fingerprint: fingerprint, + expected_content_hash: Some(content_hash_path(&destination).unwrap()), + action: MutationEntryAction::Materialize { + payload_id: payload.payload_id, + }, + }], + lock: None, + deadline_millis: 30_000, + }; + let request_bytes = environment_protocol::encode_payload(&request).unwrap(); + let request_sha = format!("sha256:{:x}", Sha256::digest(&request_bytes)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::PrepareMutationUnit { + resource_id: resource_id.clone(), + total_bytes: request_bytes.len() as u64, + sha256: request_sha, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected mutation preparation response: {message:?}"), + }; + writer + .send_transfer(4, transfer_id, &request_bytes) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 4, + message: Message::MutationAccepted { + resource_id: resource_id.clone(), + }, + } + ); + let outcome: MutationUnitOutcome = + decode_payload(&next_transfer(&mut reader, 4).await).unwrap(); + let cleanup = match outcome { + MutationUnitOutcome::Succeeded { + cleanup: Some(cleanup), + .. + } => cleanup, + outcome => panic!("unexpected mutation outcome: {outcome:?}"), + }; + assert_eq!(std::fs::read(destination.join("SKILL.md")).unwrap(), b"new"); + assert!(backup.is_dir()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + })) + .await + .unwrap(); + assert_eq!( + next_message(&mut reader).await, + Envelope { + request_id: 5, + message: Message::MutationAcknowledged { + resource_id: resource_id.clone(), + }, + } + ); + assert!(!backup.exists()); + assert!(!std::path::Path::new("/tmp") + .join(format!("skill-deck-operation-{resource_id}")) + .exists()); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::RemovePayloadSession { session_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::PayloadSessionRemoved { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 7, + message: Message::ReleaseSource { source_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::SourceReleased { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_writes_one_document_atomically_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".skill-deck/projects.json"); + let bytes = br#"{"projects":[]}"#; + let digest = format!("sha256:{:x}", Sha256::digest(bytes)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let worker_pid = child.id().expect("worker process ID"); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest.clone(), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected document preparation response: {message:?}"), + }; + writer.send_transfer(2, transfer_id, bytes).await.unwrap(); + let revision = match next_message(&mut reader).await.message { + Message::DocumentWritten { revision } => revision, + message => panic!("unexpected document write response: {message:?}"), + }; + assert_eq!(revision, digest); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + std::fs::write(&path, b"external").unwrap(); + let replacement = br#"{"projects":["demo"]}"#; + let replacement_digest = format!("sha256:{:x}", Sha256::digest(replacement)); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: Some(revision), + total_bytes: replacement.len() as u64, + sha256: replacement_digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected document preparation response: {message:?}"), + }; + writer + .send_transfer(3, transfer_id, replacement) + .await + .unwrap(); + match next_message(&mut reader).await.message { + Message::Error { code, phase, .. } => { + assert_eq!(code, "documentConflict"); + assert_eq!(phase, "documentWrite"); + } + message => panic!("unexpected document conflict response: {message:?}"), + } + assert_eq!(std::fs::read(&path).unwrap(), b"external"); + assert_eq!( + std::fs::read_dir(path.parent().unwrap()).unwrap().count(), + 1 + ); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path: path.to_string_lossy().into_owned(), + expected_revision: Some(format!("sha256:{:x}", Sha256::digest(b"external"))), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::DocumentRemoved + )); + assert!(!path.exists()); + + let abandoned_path = home.path().join(".skill-deck/abandoned.json"); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 5, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: abandoned_path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 6, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + assert!(!abandoned_path.exists()); + for request_id in [2_u64, 3, 5] { + assert!(!std::path::PathBuf::from(format!( + "/tmp/.skill-deck-document-request-{worker_pid}-{request_id}" + )) + .exists()); + } +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_executes_one_library_catalog_operation_over_stdio() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + let request = environment_protocol::LibraryOperationRequest { + operation_id: "save-catalog-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: catalog.clone(), + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: Vec::new(), + }, + deadline_millis: 30_000, + }; + let payload = environment_protocol::encode_payload(&request).unwrap(); + let digest = format!("sha256:{:x}", Sha256::digest(&payload)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareLibraryOperation { + request: environment_protocol::LibraryOperationPreparation { + total_bytes: payload.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let transfer_id = match next_message(&mut reader).await.message { + Message::TransferReady { transfer_id } => transfer_id, + message => panic!("unexpected Library preparation response: {message:?}"), + }; + writer + .send_transfer(2, transfer_id, &payload) + .await + .unwrap(); + let revision = match next_message(&mut reader).await.message { + Message::LibraryOperationCompleted { catalog_revision } => catalog_revision, + message => panic!("unexpected Library operation response: {message:?}"), + }; + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::ReadLibraryCatalog { + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + let response: environment_protocol::LibraryCatalogResponse = + environment_protocol::decode_payload(&next_transfer(&mut reader, 3).await).unwrap(); + assert!(response.present); + assert_eq!(response.bytes, catalog); + assert_eq!(response.revision, Some(revision)); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + +#[cfg(target_os = "linux")] +async fn next_transfer( + reader: &mut FramedRead, + owner_request_id: u64, +) -> Vec +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut payload = Vec::new(); + loop { + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(Envelope { + request_id, + message: Message::BeginTransfer { .. }, + }) => assert_eq!(request_id, owner_request_id), + WireRecord::PayloadChunk { bytes, .. } => payload.extend(bytes), + WireRecord::Control(Envelope { + request_id, + message: Message::TransferCompleted { .. }, + }) => { + assert_eq!(request_id, owner_request_id); + return payload; + } + record => panic!("unexpected transfer record: {record:?}"), + } + } +} + +async fn next_message( + reader: &mut FramedRead, +) -> Envelope +where + R: tokio::io::AsyncRead + Unpin, +{ + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(envelope) => envelope, + WireRecord::PayloadChunk { .. } => panic!("unexpected payload chunk"), + } +} diff --git a/src-tauri/src/application/environment_settings.rs b/src-tauri/src/application/environment_settings.rs index 251fc32f..3a8ed95d 100644 --- a/src-tauri/src/application/environment_settings.rs +++ b/src-tauri/src/application/environment_settings.rs @@ -83,7 +83,6 @@ where }); } persist(false)?; - transition.flush_deferred_source_cleanups().await; transition.commit_disabled(); } else { apply_wsl_integration_setting_with(enabled, environments, quiescence_timeout, persist) diff --git a/src-tauri/src/environment/wsl.rs b/src-tauri/src/environment/wsl.rs index cd76eb0c..2f61ffc5 100644 --- a/src-tauri/src/environment/wsl.rs +++ b/src-tauri/src/environment/wsl.rs @@ -2,9 +2,11 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; +use sha2::Digest; use specta::Type; use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio::time::timeout; @@ -21,6 +23,16 @@ use crate::error::AppError; pub mod operations; pub(crate) mod protocol; +mod worker; + +#[cfg(target_os = "windows")] +fn wsl_command() -> tokio::process::Command { + let executable = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .map(|root| root.join("System32").join("wsl.exe")) + .unwrap_or_else(|| std::path::PathBuf::from("wsl.exe")); + tokio_command(executable) +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -42,6 +54,7 @@ pub(crate) struct WslSession { struct CachedWslSession { generation: u64, session: WslSession, + worker: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,13 +110,7 @@ pub(crate) struct WslRuntime { listener: Arc>>, quiescence: Arc, source_retirement: Arc, - deferred_source_cleanups: Arc>>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WslSourceCleanupIntent { - distro_name: String, - native_root: String, + worker_artifact_directory: Option>, } #[derive(Clone)] @@ -147,6 +154,14 @@ impl WslRuntime { } pub fn new_with_support(supported: bool, wsl_integration_enabled: bool) -> Self { + Self::new_with_worker_artifact_directory(supported, wsl_integration_enabled, None) + } + + pub fn new_with_worker_artifact_directory( + supported: bool, + wsl_integration_enabled: bool, + worker_artifact_directory: Option, + ) -> Self { Self { state: Arc::new(Mutex::new(WslRuntimeState::new( supported, @@ -156,10 +171,19 @@ impl WslRuntime { listener: Arc::new(Mutex::new(None)), quiescence: Arc::new(Notify::new()), source_retirement: Arc::new(Notify::new()), - deferred_source_cleanups: Arc::new(Mutex::new(Vec::new())), + worker_artifact_directory: worker_artifact_directory.map(Arc::new), } } + #[cfg(test)] + pub(crate) fn for_wsl_test() -> Self { + Self::new_with_worker_artifact_directory( + true, + true, + Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/wsl-worker/current")), + ) + } + pub fn wsl_integration_enabled(&self) -> bool { matches!( self.state @@ -170,6 +194,18 @@ impl WslRuntime { ) } + fn worker_artifact_directory(&self, distro_name: &str) -> Result<&std::path::Path, AppError> { + self.worker_artifact_directory + .as_ref() + .map(|directory| directory.as_path()) + .ok_or_else(|| AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL worker artifact directory is not configured".to_string(), + }) + } + #[cfg(test)] pub fn set_wsl_integration_enabled(&self, enabled: bool) { let mut state = self @@ -314,35 +350,52 @@ impl WslRuntime { self.discover_with(discover).await } - async fn connect_with( - &self, - distro_name: &str, - mut connector: C, - ) -> Result - where - C: FnMut(String) -> CFut, - CFut: Future>, - { + pub async fn connect(&self, distro_name: &str) -> Result { let expected_cycle = self.enabled_cycle(distro_name)?; let reconnect_lock = self.reconnect_lock(distro_name); let _reconnect = reconnect_lock.lock().await; let permit = self.acquire_wsl_access_for_cycle(distro_name, Some(expected_cycle))?; - let mut session = connector(distro_name.to_string()).await?; - self.insert_with_permit(&mut session, &permit)?; + #[cfg(target_os = "windows")] + ensure_wsl2_candidate( + distro_name, + &discover_wsl_distributions().await.map_err(|error| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: error.to_string(), + } + })?, + )?; + let mut session = connect_wsl_environment(distro_name).await?; + let worker = + match worker::connect_worker(&session, self.worker_artifact_directory(distro_name)?) + .await + { + Ok(worker) => worker, + Err(error) => { + self.publish_connect_failure_if_cycle( + distro_name, + permit.capability_revision, + error.clone(), + ); + return Err(error); + } + }; + let worker_closed = worker.closed_receiver(); + self.insert_with_permit(&mut session, &permit, Some(worker))?; + self.monitor_worker( + distro_name.to_string(), + session.runtime_generation, + worker_closed, + ); Ok(session) } - pub async fn connect(&self, distro_name: &str) -> Result { - self.connect_with(distro_name, |distro_name| async move { - connect_wsl_environment(&distro_name).await - }) - .await - } - #[cfg(test)] pub(crate) fn insert(&self, mut session: WslSession) { if let Ok(permit) = self.acquire_wsl_access(&session.distro_name) { - let _ = self.insert_with_permit(&mut session, &permit); + let _ = self.insert_with_permit(&mut session, &permit, None); } } @@ -350,6 +403,7 @@ impl WslRuntime { &self, session: &mut WslSession, permit: &WslAccessPermit, + worker: Option, ) -> Result<(), AppError> { let distro_name = session.distro_name.clone(); let key = EnvironmentKey::wsl(&distro_name); @@ -376,6 +430,7 @@ impl WslRuntime { CachedWslSession { generation, session: session.clone(), + worker, }, ); state.runtime.insert( @@ -533,6 +588,104 @@ impl WslRuntime { } } + fn publish_connect_failure_if_cycle( + &self, + distro_name: &str, + capability_revision: u64, + error: AppError, + ) { + let mut state = self + .state + .lock() + .expect("environment registry lock poisoned"); + if state.capability_revision != capability_revision + || state.capability != WslCapabilityState::Enabled + { + return; + } + state.next_generation = state.next_generation.saturating_add(1); + let revision = state.next_generation; + state.runtime.insert( + EnvironmentKey::wsl(distro_name), + EnvironmentRuntimeStatus { + revision, + status: EnvironmentStatus::Unavailable, + error: Some(error.clone()), + }, + ); + drop(state); + self.publish(EnvironmentRuntimeEvent { + capability_revision, + revision, + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + status: EnvironmentStatus::Unavailable, + error: Some(error), + }); + } + + fn monitor_worker( + &self, + distro_name: String, + generation: u64, + mut closed: tokio::sync::watch::Receiver, + ) { + let registry = self.clone(); + tokio::spawn(async move { + if !*closed.borrow() && closed.changed().await.is_err() { + return; + } + if *closed.borrow() { + registry.publish_worker_closed_if_current(&distro_name, generation); + } + }); + } + + fn publish_worker_closed_if_current(&self, distro_name: &str, generation: u64) { + let error = AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL worker session closed".to_string(), + }; + let mut state = self + .state + .lock() + .expect("environment registry lock poisoned"); + let key = EnvironmentKey::wsl(distro_name); + if state.capability != WslCapabilityState::Enabled + || state + .sessions + .get(&key) + .is_none_or(|cached| cached.generation != generation) + { + return; + } + state.sessions.remove(&key); + state.next_generation = state.next_generation.saturating_add(1); + let revision = state.next_generation; + state.runtime.insert( + key, + EnvironmentRuntimeStatus { + revision, + status: EnvironmentStatus::Unavailable, + error: Some(error.clone()), + }, + ); + let capability_revision = state.capability_revision; + drop(state); + self.publish(EnvironmentRuntimeEvent { + capability_revision, + revision, + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + status: EnvironmentStatus::Unavailable, + error: Some(error), + }); + } + async fn get_or_connect_using( &self, distro_name: &str, @@ -556,7 +709,7 @@ impl WslRuntime { return Ok((cached, permit)); } let mut session = connector(distro_name.to_string()).await?; - self.insert_with_permit(&mut session, &permit)?; + self.insert_with_permit(&mut session, &permit, None)?; self.get_cached(distro_name) .map(|cached| (cached, permit)) .ok_or_else(|| AppError::EnvironmentUnavailable { @@ -600,8 +753,6 @@ impl WslRuntime { let (initial, initial_access) = self .get_or_connect_using(distro_name, expected_cycle, &mut connector) .await?; - self.reconcile_deferred_source_cleanups(&initial.session) - .await; match operation(initial.session.clone()).await { Ok(result) => Ok(result), Err(AppError::EnvironmentUnavailable { .. }) => { @@ -624,7 +775,7 @@ impl WslRuntime { } Err(error) => return Err(error), }; - self.insert_with_permit(&mut session, &access)?; + self.insert_with_permit(&mut session, &access, None)?; self.get_cached(distro_name).ok_or_else(|| { AppError::EnvironmentUnavailable { environment: EnvironmentRef::Wsl { @@ -654,43 +805,6 @@ impl WslRuntime { } } - async fn reconcile_deferred_source_cleanups(&self, session: &WslSession) { - let pending = { - let mut intents = self - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned"); - let mut pending = Vec::new(); - let mut retained = Vec::new(); - for intent in intents.drain(..) { - if intent - .distro_name - .eq_ignore_ascii_case(&session.distro_name) - { - pending.push(intent); - } else { - retained.push(intent); - } - } - *intents = retained; - pending - }; - for intent in pending { - if crate::environment::wsl::operations::source_acquisition::cleanup_wsl_source( - session, - &intent.native_root, - ) - .await - .is_err() - { - self.deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .push(intent); - } - } - } - pub async fn with_session_retry( &self, distro_name: &str, @@ -736,15 +850,47 @@ impl WslWorkspace { &self.distro_name } - pub(crate) fn defer_source_cleanup(&self, native_root: String) { - self.registry - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .push(WslSourceCleanupIntent { - distro_name: self.distro_name.clone(), - native_root, + pub(crate) fn filesystem_inspector( + &self, + ) -> Arc { + Arc::new(operations::inspection::WslInspector::new(self.clone())) + } + + pub(crate) fn payload_storage( + &self, + ) -> Arc { + Arc::new(operations::acquire::WslPayloadSessionStorage::new( + self.clone(), + )) + } + + pub(crate) fn defer_worker_source_release( + &self, + handle: operations::source_acquisition::WorkerSourceHandle, + ) { + let workspace = self.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let Some(cached) = workspace.registry.get_cached(&workspace.distro_name) else { + return; + }; + if cached.generation != handle.generation { + return; + } + let Some(worker) = cached.worker else { + return; + }; + let _ = worker + .request_control_with_cancellation( + environment_protocol::Message::ReleaseSource { + source_id: handle.id, + }, + std::time::Duration::from_secs(10), + None, + ) + .await; }); + } } pub(crate) fn register_source_owner(&self) -> Result<(), AppError> { @@ -779,15 +925,6 @@ impl WslWorkspace { } } - #[cfg(test)] - pub(crate) fn deferred_source_cleanup_count(&self) -> usize { - self.registry - .deferred_source_cleanups - .lock() - .expect("WSL cleanup queue lock poisoned") - .len() - } - pub(crate) async fn with_access(&self, operation: O) -> Result where O: FnOnce() -> OFut, @@ -799,19 +936,485 @@ impl WslWorkspace { operation().await } - async fn with_session_retry(&self, operation: O) -> Result + pub(crate) async fn inspect_filesystem( + &self, + request: environment_protocol::InspectionRequest, + ) -> Result { + self.request_worker_payload(environment_protocol::Message::InspectFilesystem { request }) + .await + } + + pub(crate) async fn map_host_path( + &self, + path: String, + cancellation: Option, + ) -> Result { + if path.is_empty() || path.contains('\0') { + return Err(AppError::Validation { + field: Some("bridgePath".to_string()), + message: "Host bridge path is invalid".to_string(), + }); + } + let message = environment_protocol::Message::MapHostPaths { + request: environment_protocol::MapHostPathsRequest { + paths: vec![path.clone()], + deadline_millis: 10_000, + }, + }; + let response: environment_protocol::MapHostPathsResponse = match cancellation { + Some(cancellation) => { + self.request_worker_payload_with_cancellation(message, cancellation) + .await + } + None => self.request_worker_payload(message).await, + } + .map_err(|error| match error { + AppError::CapabilityUnavailable { capability, .. } + if capability == "wslPathMapping" => + { + AppError::StorageMappingUnsupported { + path: path.clone(), + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + } + } + error => error, + })?; + match response.mapped.as_slice() { + [mapped] if mapped.starts_with('/') && !mapped.contains('\0') => Ok(mapped.clone()), + _ => Err(AppError::ConfigurationCorrupted { + message: "invalid WSL path mapping response".to_string(), + }), + } + } + + pub(crate) async fn map_path_to_windows( + &self, + path: String, + ) -> Result, AppError> { + if !path.starts_with('/') || path.contains('\0') { + return Err(AppError::Validation { + field: Some("storagePath".to_string()), + message: "WSL storage path must be absolute".to_string(), + }); + } + let response: environment_protocol::MapWindowsPathsResponse = self + .request_worker_payload(environment_protocol::Message::MapPathsToWindows { + request: environment_protocol::MapWindowsPathsRequest { + paths: vec![path], + deadline_millis: 10_000, + }, + }) + .await?; + match response.mapped.as_slice() { + [mapped] => Ok(mapped.clone()), + _ => Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Windows path mapping response".to_string(), + }), + } + } + + pub(crate) async fn request_worker_payload( + &self, + message: environment_protocol::Message, + ) -> Result where - O: FnMut(WslSession) -> OFut, - OFut: Future>, + T: serde::de::DeserializeOwned, { - self.registry - .with_session_retry_in_cycle( + let payload = self.request_worker_bytes(message, None).await?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_payload_with_cancellation( + &self, + message: environment_protocol::Message, + cancellation: crate::core::mutation::CancellationSignal, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let payload = self + .request_worker_bytes(message, Some(cancellation)) + .await?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_control_once( + &self, + message: environment_protocol::Message, + cancellation: Option, + limit: std::time::Duration, + ) -> Result<(u64, environment_protocol::Message), AppError> { + let (worker, generation, _access) = self.worker_for_cycle().await?; + let result = worker + .request_control_with_cancellation(message, limit, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( &self.distro_name, - Some(self.capability_cycle), - |distro_name| async move { connect_wsl_environment(&distro_name).await }, - operation, + generation, + error.clone(), + ); + } + result.map(|message| (generation, message)) + } + + pub(crate) async fn request_worker_control_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + cancellation: Option, + limit: std::time::Duration, + ) -> Result { + let (worker, current_generation, _access) = self.worker_for_cycle().await?; + self.require_worker_generation(generation, current_generation)?; + let result = worker + .request_control_with_cancellation(message, limit, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + result + } + + pub(crate) async fn request_worker_payload_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + max_payload_bytes: usize, + cancellation: Option, + limit: std::time::Duration, + ) -> Result + where + T: serde::de::DeserializeOwned, + { + let (worker, current_generation, _access) = self.worker_for_cycle().await?; + self.require_worker_generation(generation, current_generation)?; + let result = worker + .request_payload_with_limit(message, limit, max_payload_bytes, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + let payload = result?; + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + }) + } + + pub(crate) async fn request_worker_payload_once( + &self, + message: environment_protocol::Message, + max_payload_bytes: usize, + cancellation: Option, + limit: std::time::Duration, + ) -> Result<(u64, T), AppError> + where + T: serde::de::DeserializeOwned, + { + let (worker, generation, _access) = self.worker_for_cycle().await?; + let result = worker + .request_payload_with_limit(message, limit, max_payload_bytes, cancellation) + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { + self.registry.publish_unavailable_if_current( + &self.distro_name, + generation, + error.clone(), + ); + } + let payload = result?; + let decoded = environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker response payload: {error}"), + } + })?; + Ok((generation, decoded)) + } + + pub(crate) async fn request_worker_bytes_for_generation( + &self, + generation: u64, + message: environment_protocol::Message, + max_payload_bytes: usize, + limit: std::time::Duration, + ) -> Result, AppError> { + let (worker, current_generation, _access) = self.worker_for_cycle().await?; + self.require_worker_generation(generation, current_generation)?; + worker + .request_payload_with_limit(message, limit, max_payload_bytes, None) + .await + } + + pub(crate) async fn send_worker_transfer_for_generation( + &self, + generation: u64, + transfer_id: u64, + payload: &[u8], + max_payload_bytes: usize, + limit: std::time::Duration, + ) -> Result { + let (worker, current_generation, _access) = self.worker_for_cycle().await?; + self.require_worker_generation(generation, current_generation)?; + worker + .send_prepared_transfer(transfer_id, payload, max_payload_bytes, limit) + .await + } + + pub(crate) async fn execute_worker_mutation( + &self, + generation: u64, + resource_id: &str, + request: &environment_protocol::MutationUnitRequest, + cancellation: crate::core::mutation::CancellationSignal, + ) -> Result { + let payload = environment_protocol::encode_payload(request).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("failed to encode WSL Worker mutation request: {error}"), + } + })?; + if payload.len() > environment_protocol::MAX_MUTATION_TRANSFER_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslMutationRequestSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{:x}", sha2::Sha256::digest(&payload)); + let prepared = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::PrepareMutationUnit { + resource_id: resource_id.to_string(), + total_bytes: payload.len() as u64, + sha256: digest, + }, + Some(cancellation.clone()), + std::time::Duration::from_secs(10), + ) + .await?; + let transfer_id = match prepared { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + environment_protocol::Message::Error { code, phase, .. } => { + return Err(AppError::ExecutionFailed { + message: format!( + "WSL Worker mutation preparation failed during {phase}: {code}" + ), + }); + } + _ => { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker mutation preparation response".to_string(), + }); + } + }; + let (worker, current_generation, _access) = self.worker_for_cycle().await?; + self.require_worker_generation(generation, current_generation)?; + match worker + .send_prepared_mutation( + transfer_id, + &payload, + cancellation, + std::time::Duration::from_secs(125), + ) + .await + { + Ok(outcome) => Ok(outcome), + Err(worker::MutationSessionError { + accepted_resource_id: Some(accepted), + error, + }) => Err(AppError::RecoveryRequired { + recovery_resource_id: crate::error::RecoveryResourceId::parse(accepted) + .unwrap_or_else(|_| { + crate::error::RecoveryResourceId::parse(resource_id.to_string()) + .expect("validated mutation resource ID") + }), + message: error.to_string(), + }), + Err(worker::MutationSessionError { error, .. }) => Err(error), + } + } + + fn require_worker_generation(&self, expected: u64, actual: u64) -> Result<(), AppError> { + if expected == actual { + Ok(()) + } else { + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: "WSL Worker handle belongs to an expired session".to_string(), + }) + } + } + + async fn request_worker_bytes( + &self, + message: environment_protocol::Message, + cancellation: Option, + ) -> Result, AppError> { + for attempt in 0..=1 { + let (worker, generation, _access) = self.worker_for_cycle().await?; + let result = match &cancellation { + Some(cancellation) => { + worker + .request_payload_with_cancellation( + message.clone(), + std::time::Duration::from_secs(35), + cancellation.clone(), + ) + .await + } + None => { + worker + .request_payload(message.clone(), std::time::Duration::from_secs(35)) + .await + } + }; + match result { + Ok(payload) => return Ok(payload), + Err(error @ AppError::EnvironmentUnavailable { .. }) if attempt == 0 => { + self.registry.publish_unavailable_if_current( + &self.distro_name, + generation, + error, + ); + } + Err(error) => return Err(error), + } + } + unreachable!("WSL Worker read retry has a fixed attempt count") + } + + async fn worker_for_cycle( + &self, + ) -> Result<(worker::WorkerSession, u64, WslAccessPermit), AppError> { + let access = self + .registry + .acquire_wsl_access_for_cycle(&self.distro_name, Some(self.capability_cycle))?; + if let Some(cached) = self.registry.get_cached(&self.distro_name) { + if let Some(worker) = cached.worker { + return Ok((worker, cached.generation, access)); + } + } + drop(access); + + let reconnect_lock = self.registry.reconnect_lock(&self.distro_name); + let _reconnect = reconnect_lock.lock().await; + let access = self + .registry + .acquire_wsl_access_for_cycle(&self.distro_name, Some(self.capability_cycle))?; + if let Some(cached) = self.registry.get_cached(&self.distro_name) { + if let Some(worker) = cached.worker { + return Ok((worker, cached.generation, access)); + } + self.ensure_wsl2_candidate().await?; + let worker = match worker::connect_worker( + &cached.session, + self.registry.worker_artifact_directory(&self.distro_name)?, ) .await + { + Ok(worker) => worker, + Err(error) => { + self.registry.publish_unavailable_if_current( + &self.distro_name, + cached.generation, + error.clone(), + ); + return Err(error); + } + }; + let closed = worker.closed_receiver(); + { + let mut state = self + .registry + .state + .lock() + .expect("environment registry lock poisoned"); + let key = EnvironmentKey::wsl(&self.distro_name); + if state.capability_revision != self.capability_cycle + || state.capability != WslCapabilityState::Enabled + { + return Err(WslRuntime::disabled_error(&self.distro_name)); + } + let current = state.sessions.get_mut(&key).ok_or_else(|| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: "WSL session changed while connecting its Worker".to_string(), + } + })?; + if current.generation != cached.generation { + return Err(WslRuntime::disabled_error(&self.distro_name)); + } + current.worker = Some(worker.clone()); + } + self.registry + .monitor_worker(self.distro_name.clone(), cached.generation, closed); + return Ok((worker, cached.generation, access)); + } + + self.ensure_wsl2_candidate().await?; + let mut session = connect_wsl_environment(&self.distro_name).await?; + let worker = match worker::connect_worker( + &session, + self.registry.worker_artifact_directory(&self.distro_name)?, + ) + .await + { + Ok(worker) => worker, + Err(error) => { + self.registry.publish_connect_failure_if_cycle( + &self.distro_name, + self.capability_cycle, + error.clone(), + ); + return Err(error); + } + }; + let closed = worker.closed_receiver(); + self.registry + .insert_with_permit(&mut session, &access, Some(worker.clone()))?; + let generation = session.runtime_generation; + self.registry + .monitor_worker(self.distro_name.clone(), generation, closed); + Ok((worker, generation, access)) + } + + async fn ensure_wsl2_candidate(&self) -> Result<(), AppError> { + #[cfg(target_os = "windows")] + ensure_wsl2_candidate( + &self.distro_name, + &discover_wsl_distributions().await.map_err(|error| { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: self.distro_name.clone(), + }, + message: error.to_string(), + } + })?, + )?; + Ok(()) } #[cfg(test)] @@ -886,23 +1489,6 @@ impl WslDisableTransition { timeout(limit, wait).await.map_err(|_| ()) } - pub(crate) async fn flush_deferred_source_cleanups(&self) { - let sessions = self - .registry - .state - .lock() - .expect("environment registry lock poisoned") - .sessions - .values() - .map(|cached| cached.session.clone()) - .collect::>(); - for session in sessions { - self.registry - .reconcile_deferred_source_cleanups(&session) - .await; - } - } - pub(crate) async fn wait_for_source_retirement( &self, limit: std::time::Duration, @@ -997,13 +1583,25 @@ impl Drop for WslEnableTransition { } pub fn parse_wsl_list_output(bytes: &[u8]) -> Vec { - let decoded = if bytes.len() >= 2 && bytes.len().is_multiple_of(2) { + let looks_utf16 = bytes.len() >= 2 + && bytes.len().is_multiple_of(2) + && bytes + .iter() + .skip(1) + .step_by(2) + .filter(|byte| **byte == 0) + .count() + > bytes.len() / 8; + let decoded = if looks_utf16 { let (pairs, remainder) = bytes.as_chunks::<2>(); debug_assert!(remainder.is_empty()); - let utf16 = pairs + let mut utf16 = pairs .iter() .map(|pair| u16::from_le_bytes(*pair)) .collect::>(); + if utf16.first() == Some(&0xfeff) { + utf16.remove(0); + } String::from_utf16(&utf16).unwrap_or_else(|_| String::from_utf8_lossy(bytes).into_owned()) } else { String::from_utf8_lossy(bytes).into_owned() @@ -1012,10 +1610,41 @@ pub fn parse_wsl_list_output(bytes: &[u8]) -> Vec { .lines() .map(|line| line.trim_matches(['\0', '\r', ' ', '\t'])) .filter(|line| !line.is_empty()) - .map(str::to_string) + .filter_map(parse_wsl_verbose_line) + .filter_map(|(name, version)| (version == 2).then_some(name)) .collect() } +fn parse_wsl_verbose_line(line: &str) -> Option<(String, u8)> { + let line = line.strip_prefix('*').unwrap_or(line).trim_start(); + let columns = line + .split('\t') + .flat_map(|column| column.split(" ")) + .map(str::trim) + .filter(|column| !column.is_empty()) + .collect::>(); + if columns.len() < 3 { + return None; + } + let version = columns.last()?.parse().ok()?; + Some((columns[0].to_string(), version)) +} + +fn ensure_wsl2_candidate(distro_name: &str, candidates: &[String]) -> Result<(), AppError> { + if candidates + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(distro_name)) + { + return Ok(()); + } + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "the distribution is not an available WSL 2 environment".to_string(), + }) +} + enum WslDiscoveryCommandOutcome { TimedOut, SpawnFailed(std::io::Error), @@ -1036,7 +1665,7 @@ fn interpret_wsl_discovery_outcome( Ok(Vec::new()) } WslDiscoveryCommandOutcome::TimedOut => Err(AppError::EnvironmentDiscoveryFailed { - message: "wsl.exe --list --quiet timed out".to_string(), + message: "wsl.exe --list --verbose timed out".to_string(), }), WslDiscoveryCommandOutcome::SpawnFailed(error) => { Err(AppError::EnvironmentDiscoveryFailed { @@ -1052,7 +1681,7 @@ fn interpret_wsl_discovery_outcome( let message = String::from_utf8_lossy(&stderr).trim().to_string(); Err(AppError::EnvironmentDiscoveryFailed { message: if message.is_empty() { - "wsl.exe --list --quiet exited unsuccessfully".to_string() + "wsl.exe --list --verbose exited unsuccessfully".to_string() } else { message }, @@ -1106,8 +1735,8 @@ pub fn parse_wsl_session_output(distro_name: &str, bytes: &[u8]) -> Result Result, AppError> { - let mut command = tokio_command("wsl.exe"); - command.args(["--list", "--quiet"]); + let mut command = wsl_command(); + command.args(["--list", "--verbose"]); let outcome = match timeout(Duration::from_secs(10), command.output()).await { Err(_) => WslDiscoveryCommandOutcome::TimedOut, Ok(Err(error)) => WslDiscoveryCommandOutcome::SpawnFailed(error), @@ -1128,7 +1757,7 @@ async fn discover_wsl_distributions() -> Result, AppError> { #[cfg(target_os = "windows")] async fn connect_wsl_environment(distro_name: &str) -> Result { const SCRIPT: &str = include_str!("wsl/scripts/session.sh"); - let mut command = tokio_command("wsl.exe"); + let mut command = wsl_command(); command.args([ "--distribution", distro_name, @@ -1814,6 +2443,36 @@ mod tests { ); } + #[tokio::test] + async fn current_worker_closure_invalidates_the_environment() { + let registry = WslRuntime::default(); + registry.insert(sample_session("Ubuntu", "alice")); + let generation = registry.get("Ubuntu").unwrap().runtime_generation; + let (closed_tx, closed_rx) = tokio::sync::watch::channel(false); + registry.monitor_worker("Ubuntu".to_string(), generation, closed_rx); + + closed_tx.send(true).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if registry + .runtime_status("Ubuntu") + .is_some_and(|runtime| runtime.status == EnvironmentStatus::Unavailable) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + assert!(registry.get("Ubuntu").is_none()); + assert!(matches!( + registry.runtime_status("Ubuntu").unwrap().error, + Some(AppError::EnvironmentUnavailable { .. }) + )); + } + #[tokio::test] async fn concurrent_session_failures_share_one_reconnect() { let registry = Arc::new(WslRuntime::default()); @@ -1911,17 +2570,30 @@ mod tests { } #[test] - fn parses_utf16_wsl_list_and_removes_nul_and_blank_lines() { - let text = "Ubuntu-24.04\0\r\nDebian\0\r\n\r\n"; - let bytes = text + fn parses_utf8_and_utf16_verbose_wsl_lists() { + let text = " NAME STATE VERSION\r\n* Ubuntu-24.04 Running 2\r\n Legacy Stopped 1\r\n Imported Distro Stopped 2\r\n\r\n"; + let utf16 = text .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); + let expected = vec!["Ubuntu-24.04", "Imported Distro"]; - assert_eq!( - parse_wsl_list_output(&bytes), - vec!["Ubuntu-24.04", "Debian"] - ); + assert_eq!(parse_wsl_list_output(text.as_bytes()), expected); + assert_eq!(parse_wsl_list_output(&utf16), expected); + } + + #[test] + fn connection_requires_a_discovered_wsl2_candidate() { + let candidates = vec!["Ubuntu".to_string(), "Debian".to_string()]; + + super::ensure_wsl2_candidate("ubuntu", &candidates).unwrap(); + assert!(matches!( + super::ensure_wsl2_candidate("Legacy", &candidates), + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { ref distro_name }, + .. + }) if distro_name == "Legacy" + )); } #[test] @@ -1999,18 +2671,27 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn bundled_session_script_reports_the_complete_session_baseline() { + fn bundled_session_script_reports_identity_without_business_tool_probes() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("session fixture"); let bin = temp.path().join("bin"); - let probe_parent = temp.path().join("probe"); std::fs::create_dir_all(&bin).expect("fixture bin"); - std::fs::create_dir_all(&probe_parent).expect("probe parent"); - let flock = bin.join("flock"); - std::fs::write(&flock, "#!/bin/sh\nexit 99\n").expect("failing flock fixture"); - std::fs::set_permissions(&flock, std::fs::Permissions::from_mode(0o755)) - .expect("make flock executable"); + for command in [ + "git", + "timeout", + "xargs", + "sort", + "sha256sum", + "readlink", + "stat", + ] { + let command_path = bin.join(command); + std::fs::write(&command_path, "#!/bin/sh\nexit 99\n") + .expect("failing business tool fixture"); + std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) + .expect("make business tool fixture executable"); + } let path = format!( "{}:{}", bin.display(), @@ -2023,8 +2704,7 @@ mod tests { .arg("--") .arg("session") .env("GROK_HOME", "/opt/grok") - .env("PATH", path) - .env("TMPDIR", &probe_parent), + .env("PATH", path), std::time::Duration::from_secs(10), ) .expect("session script"); @@ -2040,154 +2720,6 @@ mod tests { assert!(!session.user.is_empty()); assert!(session.home.starts_with('/')); assert_eq!(session.environment["GROK_HOME"], "/opt/grok"); - assert!( - std::fs::read_dir(probe_parent) - .expect("probe parent remains readable") - .next() - .is_none(), - "session capability probe must clean up its temporary directory" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn bundled_session_script_rejects_each_missing_baseline_tool() { - use std::os::unix::fs::PermissionsExt; - - for command in [ - "git", - "timeout", - "xargs", - "sort", - "sha256sum", - "readlink", - "stat", - ] { - let temp = tempfile::tempdir().expect("temporary command directory"); - let command_path = temp.path().join(command); - std::fs::write(&command_path, "#!/bin/sh\nexit 1\n").expect("write failing command"); - std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) - .expect("make failing command executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = command_output_with_timeout( - Command::new("/bin/sh") - .arg("-c") - .arg(include_str!("wsl/scripts/session.sh")) - .arg("--") - .arg("session") - .env("PATH", path), - std::time::Duration::from_secs(10), - ) - .expect("session script"); - - assert!( - !output.status.success(), - "unavailable {command} must reject the WSL session" - ); - assert!( - String::from_utf8_lossy(&output.stderr) - .to_ascii_lowercase() - .contains(command), - "{command} failure returned stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn bundled_session_script_rejects_incompatible_baseline_behavior() { - use std::os::unix::fs::PermissionsExt; - - let incompatible_commands = [ - ( - "timeout", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "--kill-after=1s" ] && exit 64 -done -exit 0 -"#, - ), - ( - "xargs", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "-r" ] && exit 64 -done -printf 'a\nb\n' -"#, - ), - ( - "sort", - r#"#!/bin/sh -printf 'a\0b\0' -"#, - ), - ( - "sha256sum", - r#"#!/bin/sh -[ "$#" -eq 0 ] || exit 64 -printf '%s -\n' 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' -"#, - ), - ( - "readlink", - r#"#!/bin/sh -for argument in "$@"; do - [ "$argument" = "--" ] && exit 64 -done -printf '/\n' -"#, - ), - ]; - let mut unexpected_successes = Vec::new(); - - for (command, script) in incompatible_commands { - let temp = tempfile::tempdir().expect("temporary command directory"); - let command_path = temp.path().join(command); - std::fs::write(&command_path, script).expect("write incompatible command"); - std::fs::set_permissions(&command_path, std::fs::Permissions::from_mode(0o755)) - .expect("make incompatible command executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap_or_default() - ); - - let output = command_output_with_timeout( - Command::new("/bin/sh") - .arg("-c") - .arg(include_str!("wsl/scripts/session.sh")) - .arg("--") - .arg("session") - .env("PATH", path), - std::time::Duration::from_secs(10), - ) - .expect("session script"); - - if output.status.success() { - unexpected_successes.push(command); - continue; - } - assert!( - String::from_utf8_lossy(&output.stderr) - .to_ascii_lowercase() - .contains(command), - "{command} incompatibility returned stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - assert!( - unexpected_successes.is_empty(), - "incompatible commands passed the WSL session baseline: {unexpected_successes:?}" - ); } #[test] @@ -2197,4 +2729,33 @@ printf '/\n' assert_eq!(session.xdg_state_home, None); } + + #[cfg(target_os = "windows")] + #[tokio::test] + #[ignore = "requires Windows with a WSL 2 distribution"] + async fn real_wsl2_worker_maps_a_windows_directory_round_trip() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let root = tempfile::tempdir().expect("Windows path fixture"); + let directory = root.path().join("Skill Deck 项目"); + std::fs::create_dir(&directory).expect("create Windows path fixture"); + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).expect("WSL workspace"); + + let mapped = workspace + .map_host_path(directory.to_string_lossy().into_owned(), None) + .await + .expect("map Windows path into WSL"); + assert!(mapped.starts_with('/')); + let round_trip = workspace + .map_path_to_windows(mapped) + .await + .expect("map WSL path into Windows") + .expect("Windows projection"); + + assert_eq!( + std::fs::canonicalize(round_trip).expect("canonical mapped path"), + std::fs::canonicalize(directory).expect("canonical fixture path") + ); + } } diff --git a/src-tauri/src/environment/wsl/protocol.rs b/src-tauri/src/environment/wsl/protocol.rs index f0f9c69e..1874912c 100644 --- a/src-tauri/src/environment/wsl/protocol.rs +++ b/src-tauri/src/environment/wsl/protocol.rs @@ -1,6 +1,5 @@ #![cfg_attr(not(target_os = "windows"), allow(dead_code))] -use std::collections::VecDeque; use std::future::pending; #[cfg(target_os = "windows")] use std::process::Stdio; @@ -11,8 +10,6 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio::time::{Duration, Instant}; -#[cfg(target_os = "windows")] -use crate::background_process::tokio_command; use crate::core::mutation::CancellationSignal; use crate::environment::types::EnvironmentRef; use crate::environment::wsl::WslSession; @@ -24,67 +21,9 @@ const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(25); const WSL_BOOTSTRAP_SCRIPT: &str = r#"printf 'skill-deck-wsl-shell-started-v1\n' >&2; script=$1; shift; exec /bin/sh -c "$script" -- "$@""#; const WSL_SHELL_STARTED_MARKER: &[u8] = b"skill-deck-wsl-shell-started-v1\n"; -#[derive(Clone, Copy)] -pub(super) enum OutputOverflowBehavior { - Fail, - PreservePrefixAndTail { prefix_bytes: usize }, -} - -#[derive(Clone, Copy)] -pub(super) struct OutputCapturePolicy { - pub(super) stdout: OutputOverflowBehavior, - pub(super) stderr: OutputOverflowBehavior, -} - -const STRICT_OUTPUT_CAPTURE: OutputCapturePolicy = OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::Fail, -}; - -pub(super) const GIT_OUTPUT_CAPTURE: OutputCapturePolicy = OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, -}; - -struct WslCommandRequest { - pub session: WslSession, - pub script: &'static str, - pub args: Vec, - pub stdin: Vec, - pub timeout: Duration, - pub stdout_limit: usize, - pub stderr_limit: usize, - pub cancellation: Option, -} - -pub(super) type WslExitMapper = fn(Option, &str) -> Option; - -pub(super) fn no_wsl_exit_mapping(_: Option, _: &str) -> Option { - None -} - -pub(super) struct WslOperationDescriptor { - pub(super) subcommand: &'static str, - pub(super) script: &'static str, - pub(super) map_exit: WslExitMapper, -} - -pub(super) const fn wsl_operation( - _name: &'static str, - subcommand: &'static str, - script: &'static str, -) -> WslOperationDescriptor { - WslOperationDescriptor { - subcommand, - script, - map_exit: no_wsl_exit_mapping, - } -} - -pub(super) struct WslOperationRequest { +pub(super) struct WslCommandRequest { pub(super) session: WslSession, + pub(super) script: &'static str, pub(super) args: Vec, pub(super) stdin: Vec, pub(super) timeout: Duration, @@ -100,9 +39,7 @@ pub(super) struct WslCommandOutput { pub(super) exit_code: Option, } -struct WslCommandRunner; - -pub(super) struct WslOperationExecutor; +pub(super) struct WslCommandRunner; pub(crate) fn build_wsl_exec_args( distro_name: &str, @@ -125,14 +62,6 @@ pub(crate) fn build_wsl_exec_args( args } -pub(super) fn decode_nul_records(bytes: &[u8]) -> Vec { - bytes - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .map(|record| String::from_utf8_lossy(record).into_owned()) - .collect() -} - fn build_wsl_runner_exec_args(request: &WslCommandRequest) -> Vec { let mut args = Vec::with_capacity(request.args.len() + 1); args.push(request.script.to_string()); @@ -145,89 +74,31 @@ fn build_wsl_runner_exec_args(request: &WslCommandRequest) -> Vec { ) } -fn operation_command_request( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, -) -> WslCommandRequest { - let mut args = - Vec::with_capacity(request.args.len() + usize::from(!descriptor.subcommand.is_empty())); - if !descriptor.subcommand.is_empty() { - args.push(descriptor.subcommand.to_string()); - } - args.extend(request.args); - WslCommandRequest { - session: request.session, - script: descriptor.script, - args, - stdin: request.stdin, - timeout: request.timeout, - stdout_limit: request.stdout_limit, - stderr_limit: request.stderr_limit, - cancellation: request.cancellation, - } -} - async fn read_output( mut reader: R, stream: &'static str, limit: usize, - overflow_behavior: OutputOverflowBehavior, limit_tx: mpsc::Sender, ) -> Result, AppError> where R: AsyncRead + Unpin, { - let prefix_capacity = match overflow_behavior { - OutputOverflowBehavior::Fail => limit, - OutputOverflowBehavior::PreservePrefixAndTail { prefix_bytes } => prefix_bytes.min(limit), - }; - let tail_capacity = limit.saturating_sub(prefix_capacity); - let mut output = Vec::with_capacity(prefix_capacity.min(8 * 1024)); - let mut tail = VecDeque::with_capacity(tail_capacity.min(8 * 1024)); - let mut tail_truncated = false; + let mut output = Vec::with_capacity(limit.min(8 * 1024)); let mut chunk = [0u8; 8 * 1024]; loop { let read = reader.read(&mut chunk).await?; if read == 0 { - if tail_truncated { - while let Some(byte) = tail.pop_front() { - if byte == b'\n' { - break; - } - } - } - output.extend(tail); return Ok(output); } - - match overflow_behavior { - OutputOverflowBehavior::Fail => { - if output.len().saturating_add(read) > limit { - let error = AppError::WslOutputLimitExceeded { - stream: stream.to_string(), - limit: u32::try_from(limit).unwrap_or(u32::MAX), - }; - let _ = limit_tx.send(error.clone()).await; - return Err(error); - } - output.extend_from_slice(&chunk[..read]); - } - OutputOverflowBehavior::PreservePrefixAndTail { .. } => { - let prefix_remaining = prefix_capacity.saturating_sub(output.len()); - let prefix_bytes = prefix_remaining.min(read); - output.extend_from_slice(&chunk[..prefix_bytes]); - if tail_capacity == 0 { - continue; - } - for byte in &chunk[prefix_bytes..read] { - if tail.len() == tail_capacity { - tail.pop_front(); - tail_truncated = true; - } - tail.push_back(*byte); - } - } + if output.len().saturating_add(read) > limit { + let error = AppError::WslOutputLimitExceeded { + stream: stream.to_string(), + limit: u32::try_from(limit).unwrap_or(u32::MAX), + }; + let _ = limit_tx.send(error.clone()).await; + return Err(error); } + output.extend_from_slice(&chunk[..read]); } } @@ -263,7 +134,6 @@ async fn supervise_child( timeout_duration: Duration, stdout_limit: usize, stderr_limit: usize, - output_capture: OutputCapturePolicy, cancellation: Option, ) -> Result { let mut stdin = child.stdin.take().ok_or_else(|| AppError::Custom { @@ -284,16 +154,9 @@ async fn supervise_child( stdout, "stdout", stdout_limit, - output_capture.stdout, limit_tx.clone(), )); - let mut stderr_reader = tokio::spawn(read_output( - stderr, - "stderr", - stderr_limit, - output_capture.stderr, - limit_tx, - )); + let mut stderr_reader = tokio::spawn(read_output(stderr, "stderr", stderr_limit, limit_tx)); let deadline = Instant::now() + timeout_duration; let deadline_sleep = tokio::time::sleep_until(deadline); tokio::pin!(deadline_sleep); @@ -410,48 +273,11 @@ fn interpret_wsl_transport_output( Ok(output) } -fn interpret_wsl_operation_output( - output: WslCommandOutput, - map_exit: F, -) -> Result -where - F: Fn(Option, &str) -> Option, -{ - if output.exit_code != Some(0) { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if let Some(error) = map_exit(output.exit_code, &stderr) { - return Err(error); - } - return Err(AppError::WslCommandFailed { - exit_code: output.exit_code, - stderr, - }); - } - Ok(output) -} - -#[cfg(test)] -fn interpret_wsl_command_output( - session: &WslSession, - output: WslCommandOutput, -) -> Result { - let output = interpret_wsl_transport_output(session, output)?; - interpret_wsl_operation_output(output, |_, _| None) -} - impl WslCommandRunner { #[cfg(target_os = "windows")] pub async fn run(request: WslCommandRequest) -> Result { - Self::run_with_output_capture(request, STRICT_OUTPUT_CAPTURE).await - } - - #[cfg(target_os = "windows")] - async fn run_with_output_capture( - request: WslCommandRequest, - output_capture: OutputCapturePolicy, - ) -> Result { let args = build_wsl_runner_exec_args(&request); - let mut command = tokio_command("wsl.exe"); + let mut command = super::wsl_command(); command .args(args) .stdin(Stdio::piped()) @@ -473,7 +299,6 @@ impl WslCommandRunner { request.timeout, request.stdout_limit, request.stderr_limit, - output_capture, request.cancellation, ) .await?; @@ -482,14 +307,6 @@ impl WslCommandRunner { #[cfg(not(target_os = "windows"))] pub async fn run(request: WslCommandRequest) -> Result { - Self::run_with_output_capture(request, STRICT_OUTPUT_CAPTURE).await - } - - #[cfg(not(target_os = "windows"))] - async fn run_with_output_capture( - request: WslCommandRequest, - _output_capture: OutputCapturePolicy, - ) -> Result { Err(AppError::EnvironmentUnavailable { environment: EnvironmentRef::Wsl { distro_name: request.session.distro_name, @@ -499,29 +316,6 @@ impl WslCommandRunner { } } -impl WslOperationExecutor { - pub(super) async fn execute( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, - ) -> Result { - let output = WslCommandRunner::run(operation_command_request(descriptor, request)).await?; - interpret_wsl_operation_output(output, descriptor.map_exit) - } - - pub(super) async fn execute_with_output_capture( - descriptor: &WslOperationDescriptor, - request: WslOperationRequest, - output_capture: OutputCapturePolicy, - ) -> Result { - let output = WslCommandRunner::run_with_output_capture( - operation_command_request(descriptor, request), - output_capture, - ) - .await?; - interpret_wsl_operation_output(output, descriptor.map_exit) - } -} - #[cfg(test)] #[allow( clippy::disallowed_methods, @@ -533,10 +327,8 @@ mod tests { use tokio::time::Duration; use super::{ - build_wsl_exec_args, decode_nul_records, interpret_wsl_command_output, - interpret_wsl_operation_output, interpret_wsl_transport_output, operation_command_request, - WslCommandOutput, WslCommandRequest, WslCommandRunner, WslOperationDescriptor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, + build_wsl_exec_args, interpret_wsl_transport_output, WslCommandOutput, WslCommandRequest, + WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, WSL_SHELL_STARTED_MARKER, }; use crate::core::mutation::CancellationSignal; @@ -551,7 +343,7 @@ mod tests { use tokio::process::{Child, Command}; use tokio::time::timeout; - use super::super::{supervise_child, OutputCapturePolicy, OutputOverflowBehavior}; + use super::super::supervise_child; use super::*; fn spawn_shell(script: &str) -> (Child, u32) { @@ -580,17 +372,10 @@ mod tests { #[tokio::test] async fn supervisor_collects_stdout_stderr_and_exit_code() { let (child, _) = spawn_shell("printf stdout; printf stderr >&2; exit 7"); - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 1024, - 1024, - super::super::STRICT_OUTPUT_CAPTURE, - None, - ) - .await - .expect("supervise child"); + let output = + supervise_child(child, Vec::new(), Duration::from_secs(1), 1024, 1024, None) + .await + .expect("supervise child"); assert_eq!(output.stdout, b"stdout"); assert_eq!(output.stderr, b"stderr"); @@ -603,17 +388,9 @@ mod tests { [("printf 12345", "stdout"), ("printf 12345 >&2", "stderr")] { let (child, _) = spawn_shell(script); - let error = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 4, - 4, - super::super::STRICT_OUTPUT_CAPTURE, - None, - ) - .await - .expect_err("output must be bounded"); + let error = supervise_child(child, Vec::new(), Duration::from_secs(1), 4, 4, None) + .await + .expect_err("output must be bounded"); assert!(matches!( error, @@ -623,67 +400,6 @@ mod tests { } } - #[tokio::test] - async fn supervisor_preserves_marker_and_stderr_tail_for_git_output() { - let script = format!( - "printf '{}\\n' >&2; printf 'discard-me-1234567890\\n' >&2; printf 'useful-tail\\n' >&2", - String::from_utf8_lossy(WSL_SHELL_STARTED_MARKER).trim_end() - ); - let (child, _) = spawn_shell(&script); - - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 64, - 48, - OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, - }, - None, - ) - .await - .expect("Git stderr should retain a bounded tail"); - - assert!(output.stderr.starts_with(WSL_SHELL_STARTED_MARKER)); - assert!(output.stderr.ends_with(b"useful-tail\n")); - assert!(output.stderr.len() <= 48); - } - - #[tokio::test] - async fn supervisor_discards_a_truncated_sensitive_stderr_line() { - let script = format!( - "printf '{}\\nAuthorization: Bearer ' >&2; printf '%080d' 0 >&2; printf '\\nvisible diagnostic\\n' >&2", - String::from_utf8_lossy(WSL_SHELL_STARTED_MARKER).trim_end() - ); - let (child, _) = spawn_shell(&script); - - let output = supervise_child( - child, - Vec::new(), - Duration::from_secs(1), - 64, - 64, - OutputCapturePolicy { - stdout: OutputOverflowBehavior::Fail, - stderr: OutputOverflowBehavior::PreservePrefixAndTail { - prefix_bytes: WSL_SHELL_STARTED_MARKER.len(), - }, - }, - None, - ) - .await - .expect("Git stderr should retain complete diagnostic lines"); - - assert_eq!( - &output.stderr[WSL_SHELL_STARTED_MARKER.len()..], - b"visible diagnostic\n" - ); - } - #[tokio::test] async fn supervisor_kills_and_reaps_a_timed_out_child_and_writer() { let (child, pid) = spawn_shell("exec sleep 5"); @@ -693,7 +409,6 @@ mod tests { Duration::from_millis(25), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ) .await @@ -719,7 +434,6 @@ mod tests { Duration::from_secs(1), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, Some(cancellation), ) .await @@ -740,7 +454,6 @@ mod tests { Duration::from_secs(1), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ), ) @@ -759,7 +472,6 @@ mod tests { Duration::from_millis(25), 1024, 1024, - super::super::STRICT_OUTPUT_CAPTURE, None, ) .await @@ -808,14 +520,6 @@ mod tests { assert_eq!(args[9], "$(touch /tmp/pwned)"); } - #[test] - fn decodes_nul_delimited_versioned_records() { - assert_eq!( - decode_nul_records(b"1\0first\0second\0"), - vec!["1", "first", "second"] - ); - } - #[test] fn command_request_carries_bounded_defaults_and_cancellation() { let cancellation = CancellationSignal::default(); @@ -836,12 +540,12 @@ mod tests { } #[test] - fn shell_start_marker_distinguishes_session_and_business_failures() { + fn shell_start_marker_distinguishes_launcher_and_bootstrap_failures() { let session = test_session(); let environment = EnvironmentRef::Wsl { distro_name: "Ubuntu".to_string(), }; - let unavailable = interpret_wsl_command_output( + let unavailable = interpret_wsl_transport_output( &session, WslCommandOutput { stdout: Vec::new(), @@ -860,7 +564,7 @@ mod tests { let mut marked_stderr = WSL_SHELL_STARTED_MARKER.to_vec(); marked_stderr.extend_from_slice(b"permission denied"); - let business_failure = interpret_wsl_command_output( + let bootstrap_failure = interpret_wsl_transport_output( &session, WslCommandOutput { stdout: Vec::new(), @@ -868,14 +572,9 @@ mod tests { exit_code: Some(13), }, ) - .expect_err("business failure"); - assert!(matches!( - business_failure, - AppError::WslCommandFailed { - exit_code: Some(13), - stderr, - } if stderr == "permission denied" - )); + .expect("the bootstrap shell started"); + assert_eq!(bootstrap_failure.exit_code, Some(13)); + assert_eq!(bootstrap_failure.stderr, b"permission denied"); } #[test] @@ -898,78 +597,12 @@ mod tests { assert_eq!(output.stderr, b"permission denied"); } - #[test] - fn operation_exit_mapping_is_local_and_unknown_codes_fall_back() { - let map_exit = |exit_code, _stderr: &str| match exit_code { - Some(44) => Some(AppError::PathNotFound { - path: "/tmp/SKILL.md".to_string(), - }), - _ => None, - }; - - let known = interpret_wsl_operation_output( - WslCommandOutput { - stdout: Vec::new(), - stderr: Vec::new(), - exit_code: Some(44), - }, - map_exit, - ) - .expect_err("known operation error"); - assert!(matches!(known, AppError::PathNotFound { .. })); - - let unknown = interpret_wsl_operation_output( - WslCommandOutput { - stdout: Vec::new(), - stderr: b"unexpected".to_vec(), - exit_code: Some(99), - }, - map_exit, - ) - .expect_err("unknown operation error"); - assert!(matches!( - unknown, - AppError::WslCommandFailed { - exit_code: Some(99), - stderr, - } if stderr == "unexpected" - )); - } - - #[test] - fn operation_descriptor_prepends_its_fixed_subcommand() { - fn no_mapping(_: Option, _: &str) -> Option { - None - } - - static DESCRIPTOR: WslOperationDescriptor = WslOperationDescriptor { - subcommand: "stage", - script: "printf ok", - map_exit: no_mapping, - }; - let operation = WslOperationRequest { - session: test_session(), - args: vec!["root".to_string()], - stdin: b"request".to_vec(), - timeout: Duration::from_secs(5), - stdout_limit: 32, - stderr_limit: 64, - cancellation: None, - }; - - let command = operation_command_request(&DESCRIPTOR, operation); - - assert_eq!(command.script, "printf ok"); - assert_eq!(command.args, vec!["stage", "root"]); - assert_eq!(command.stdin, b"request"); - } - #[test] fn shell_start_marker_is_removed_from_successful_stderr() { let mut stderr = WSL_SHELL_STARTED_MARKER.to_vec(); stderr.extend_from_slice(b"warning"); - let output = interpret_wsl_command_output( + let output = interpret_wsl_transport_output( &test_session(), WslCommandOutput { stdout: b"ok".to_vec(), diff --git a/src-tauri/src/environment/wsl/scripts/session.sh b/src-tauri/src/environment/wsl/scripts/session.sh index 8a52876f..b0023eb8 100644 --- a/src-tauri/src/environment/wsl/scripts/session.sh +++ b/src-tauri/src/environment/wsl/scripts/session.sh @@ -3,50 +3,10 @@ subcommand=$1 shift case "$subcommand" in session) - fail() { - printf '%s\n' "$1" >&2 - exit 69 - } - - git --version >/dev/null 2>&1 || fail 'Skill Deck requires Git in the WSL environment' - timeout --kill-after=1s 1s /bin/sh -c 'exit 0' >/dev/null 2>&1 \ - || fail 'Skill Deck requires GNU timeout in the WSL environment' - - probe_root=${TMPDIR:-/tmp}/skill-deck-session-probe-$$ - probe_root_created=0 - if mkdir -- "$probe_root" 2>/dev/null; then - probe_root_created=1 - trap 'rm -rf -- "$probe_root"' EXIT HUP INT TERM - printf 'a\nb\n' > "$probe_root/xargs-expected" - if ! printf 'a\0b\0' | xargs -0 -r -n1 printf '%s\n' > "$probe_root/xargs-actual" 2>/dev/null; then - fail 'Skill Deck requires xargs with -0 and -r support in the WSL environment' - fi - if ! cmp -s "$probe_root/xargs-expected" "$probe_root/xargs-actual"; then - fail 'Skill Deck requires xargs with -0 and -r support in the WSL environment' - fi - printf 'a\0B\0' > "$probe_root/sort-expected" - if ! printf 'B\0a\0' | LC_ALL=C sort -z -f > "$probe_root/sort-actual" 2>/dev/null; then - fail 'Skill Deck requires sort with -z and -f support in the WSL environment' - fi - if ! cmp -s "$probe_root/sort-expected" "$probe_root/sort-actual"; then - fail 'Skill Deck requires sort with -z and -f support in the WSL environment' - fi - : > "$probe_root/sha256-empty" - else - fail 'Skill Deck could not create a WSL capability probe directory' - fi - [ "$(sha256sum -- "$probe_root/sha256-empty" 2>/dev/null | awk '{print $1}')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ] \ - || fail 'Skill Deck requires sha256sum with -- support in the WSL environment' - [ "$(readlink -f -- / 2>/dev/null)" = / ] \ - || fail 'Skill Deck requires readlink with -f and -- support in the WSL environment' - stat -Lc '%d:%i:%f:%s:%Y:%y' -- / >/dev/null 2>&1 \ - || fail 'Skill Deck requires GNU-compatible stat in the WSL environment' - printf '4\0'; id -un | tr -d '\n'; printf '\0'; id -u | tr -d '\n'; printf '\0' + user=$(id -un) || exit 69 + uid=$(id -u) || exit 69 + printf '4\0%s\0%s\0' "$user" "$uid" printf '%s\0' "$HOME" "${XDG_STATE_HOME:-}" "${XDG_CONFIG_HOME:-$HOME/.config}" "${CODEX_HOME:-}" "${CLAUDE_CONFIG_DIR:-}" "${VIBE_HOME:-}" "${HERMES_HOME:-}" "${AUTOHAND_HOME:-}" "${GROK_HOME:-}" - if [ "$probe_root_created" = 1 ]; then - rm -rf -- "$probe_root" 2>/dev/null || true - fi - trap - EXIT HUP INT TERM ;; *) printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 diff --git a/src-tauri/src/environment/wsl/worker.rs b/src-tauri/src/environment/wsl/worker.rs new file mode 100644 index 00000000..ddeb5896 --- /dev/null +++ b/src-tauri/src/environment/wsl/worker.rs @@ -0,0 +1,2018 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use environment_protocol::{ + codec, decode, spawn_writer, Envelope, Message, PathKind, ProtocolWriter, WireRecord, + MAX_PAYLOAD_CHUNK_BYTES, MAX_RESPONSE_TRANSFER_BYTES, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +#[cfg(target_os = "windows")] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncRead, AsyncWrite}; +#[cfg(target_os = "windows")] +use tokio::process::Child; +use tokio::sync::{oneshot, watch}; +use tokio::task::AbortHandle; +use tokio::time::{sleep, Duration}; +use tokio_util::codec::FramedRead; + +use crate::core::mutation::CancellationSignal; +use crate::environment::types::EnvironmentRef; +#[cfg(target_os = "windows")] +use crate::environment::wsl::protocol::{ + WslCommandRequest, WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, +}; +use crate::error::AppError; + +const TOMBSTONE_LIMIT: usize = 256; +const WORKER_TARGET: &str = "x86_64-unknown-linux-musl"; +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(target_os = "windows")] +const WORKER_START_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "windows")] +const WORKER_STDERR_LIMIT: usize = 64 * 1024; +const BOOTSTRAP_SCRIPT: &str = r#"set -eu +expected=$1 +target="$HOME/.skill-deck/runtime/wsl-worker/current/worker" +directory=${target%/*} +temporary="$target.tmp.$$" +mkdir -p "$directory" +umask 077 +if [ -e "$target" ] || [ -L "$target" ]; then + if [ ! -f "$target" ] || [ -L "$target" ]; then + printf '%s\n' 'WSL worker target is not a regular file' >&2 + exit 71 + fi + actual=$(sha256sum "$target" | awk '{print $1}') + if [ "sha256:$actual" = "$expected" ]; then + cat >/dev/null + chmod 700 "$target" + exit 0 + fi +fi +trap 'rm -f "$temporary"' EXIT HUP INT TERM +cat > "$temporary" +actual=$(sha256sum "$temporary" | awk '{print $1}') +if [ "sha256:$actual" != "$expected" ]; then + printf '%s\n' 'WSL worker digest mismatch' >&2 + exit 70 +fi +chmod 700 "$temporary" +if [ -e "$target" ] || [ -L "$target" ]; then + if [ ! -f "$target" ] || [ -L "$target" ]; then + printf '%s\n' 'WSL worker target changed type during deployment' >&2 + exit 71 + fi +fi +mv -f "$temporary" "$target" +trap - EXIT HUP INT TERM +"#; + +#[derive(Debug)] +struct WorkerArtifact { + bytes: Vec, + build_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkerManifest { + build_id: String, + sha256: String, + target: String, +} + +impl WorkerArtifact { + fn load_from(directory: &Path) -> Result { + let bytes = std::fs::read(directory.join("worker")).map_err(|error| error.to_string())?; + let manifest: WorkerManifest = serde_json::from_slice( + &std::fs::read(directory.join("manifest.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let actual = format!("sha256:{:x}", Sha256::digest(&bytes)); + if manifest.target != WORKER_TARGET { + return Err(format!( + "unsupported WSL worker target: {}", + manifest.target + )); + } + if manifest.build_id != manifest.sha256 || manifest.sha256 != actual { + return Err("WSL worker bytes do not match the manifest".to_string()); + } + Ok(Self { + bytes, + build_id: manifest.build_id, + }) + } +} + +#[derive(Clone)] +pub(super) struct WorkerSession { + inner: Arc, +} + +struct WorkerSessionInner { + distro_name: String, + next_request_id: AtomicU64, + routes: Arc>, + writer: ProtocolWriter, + closed: watch::Sender, + reader_task: AbortHandle, + writer_task: AbortHandle, + #[cfg(target_os = "windows")] + stderr_task: Option, + #[cfg(target_os = "windows")] + child: Mutex>, +} + +#[derive(Default)] +struct ResponseRoutes { + pending: HashMap, + active_transfer: Option, + tombstones: VecDeque, + tombstone_set: HashSet, +} + +struct PendingResponse { + expected: ExpectedResponse, + max_payload_bytes: usize, + accepted_resource: Option>>>, + sender: oneshot::Sender>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ExpectedResponse { + Control, + Payload, + Mutation, +} + +#[derive(Debug)] +pub(super) struct MutationSessionError { + pub error: AppError, + pub accepted_resource_id: Option, +} + +enum RoutedResponse { + Control(Message), + Payload(Vec), +} + +struct IncomingTransfer { + transfer_id: u64, + owner_request_id: u64, + total_bytes: usize, + expected_sha256: String, + received_bytes: usize, + hasher: Sha256, + payload: Option>, +} + +impl WorkerSession { + fn from_io(reader: R, writer: W, distro_name: String) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + let routes = Arc::new(Mutex::new(ResponseRoutes::default())); + let (closed, _) = watch::channel(false); + let (writer, writer_task) = spawn_writer(writer); + let reader_routes = Arc::clone(&routes); + let reader_distro = distro_name.clone(); + let reader_closed = closed.clone(); + let reader_task = tokio::spawn(async move { + let mut reader = FramedRead::new(reader, codec()); + while let Some(frame) = reader.next().await { + let record = match frame + .map_err(|error| error.to_string()) + .and_then(|bytes| decode(&bytes).map_err(|error| error.to_string())) + { + Ok(record) => record, + Err(error) => { + fail_pending(&reader_routes, unavailable(&reader_distro, error)); + let _ = reader_closed.send(true); + return; + } + }; + if let Err(error) = route_record(&reader_routes, record) { + fail_pending(&reader_routes, unavailable(&reader_distro, error)); + let _ = reader_closed.send(true); + return; + } + } + fail_pending( + &reader_routes, + unavailable(&reader_distro, "worker closed its output"), + ); + let _ = reader_closed.send(true); + }); + Self { + inner: Arc::new(WorkerSessionInner { + distro_name, + next_request_id: AtomicU64::new(1), + routes, + writer, + closed, + reader_task: reader_task.abort_handle(), + writer_task: writer_task.abort_handle(), + #[cfg(target_os = "windows")] + stderr_task: None, + #[cfg(target_os = "windows")] + child: Mutex::new(None), + }), + } + } + + #[cfg(target_os = "windows")] + fn from_child(mut child: Child, distro_name: String) -> Result { + let stdin = child + .stdin + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| unavailable(&distro_name, "worker stderr was not piped"))?; + let stderr_distro = distro_name.clone(); + let stderr_task = tokio::spawn(async move { + drain_worker_stderr(stderr, &stderr_distro).await; + }); + let mut session = Self::from_io(stdout, stdin, distro_name); + let inner = Arc::get_mut(&mut session.inner).expect("new worker session is uniquely owned"); + inner.stderr_task = Some(stderr_task.abort_handle()); + *inner.child.lock().expect("worker child lock poisoned") = Some(child); + Ok(session) + } + + async fn request(&self, message: Message, limit: Duration) -> Result { + match self + .request_response(message, limit, ExpectedResponse::Control, None) + .await? + { + RoutedResponse::Control(message) => Ok(message), + RoutedResponse::Payload(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for a control request", + )), + } + } + + pub(super) async fn request_control_with_cancellation( + &self, + message: Message, + limit: Duration, + cancellation: Option, + ) -> Result { + match self + .request_response(message, limit, ExpectedResponse::Control, cancellation) + .await? + { + RoutedResponse::Control(message) => Ok(message), + RoutedResponse::Payload(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for a control request", + )), + } + } + + pub(super) async fn request_payload( + &self, + message: Message, + limit: Duration, + ) -> Result, AppError> { + match self + .request_response(message, limit, ExpectedResponse::Payload, None) + .await? + { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, phase, .. }) => Err( + payload_control_error(&self.inner.distro_name, &code, &phase), + ), + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + pub(super) async fn request_payload_with_limit( + &self, + message: Message, + limit: Duration, + max_payload_bytes: usize, + cancellation: Option, + ) -> Result, AppError> { + match self + .request_response_with_payload_limit( + message, + limit, + ExpectedResponse::Payload, + cancellation, + max_payload_bytes, + ) + .await? + { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, phase, .. }) => Err( + payload_control_error(&self.inner.distro_name, &code, &phase), + ), + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + pub(super) async fn send_prepared_transfer( + &self, + transfer_id: u64, + payload: &[u8], + max_payload_bytes: usize, + limit: Duration, + ) -> Result { + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected: ExpectedResponse::Control, + max_payload_bytes: 0, + accepted_resource: None, + sender: response_tx, + }, + ); + if let Err(error) = self + .inner + .writer + .send_transfer_with_limit(request_id, transfer_id, payload, max_payload_bytes) + .await + { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + match tokio::time::timeout(limit, response_rx).await { + Ok(Ok(Ok(RoutedResponse::Control(message)))) => Ok(message), + Ok(Ok(Ok(RoutedResponse::Payload(_)))) => Err(unavailable( + &self.inner.distro_name, + "worker returned a payload for an inbound transfer", + )), + Ok(Ok(Err(error))) => Err(error), + Ok(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + Err(_) => { + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })) + .await; + } + Err(AppError::WslCommandTimedOut) + } + } + } + + pub(super) async fn send_prepared_mutation( + &self, + transfer_id: u64, + payload: &[u8], + cancellation: CancellationSignal, + limit: Duration, + ) -> Result { + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let accepted_resource = Arc::new(Mutex::new(None)); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected: ExpectedResponse::Mutation, + max_payload_bytes: environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + accepted_resource: Some(Arc::clone(&accepted_resource)), + sender: response_tx, + }, + ); + if let Err(error) = self + .inner + .writer + .send_transfer_with_limit( + request_id, + transfer_id, + payload, + environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + ) + .await + { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(MutationSessionError { + error: unavailable(&self.inner.distro_name, error.to_string()), + accepted_resource_id: None, + }); + } + let deadline = tokio::time::Instant::now() + limit; + let mut response_rx = response_rx; + let mut cancel_sent = false; + let waited = tokio::select! { + response = &mut response_rx => Some(response), + _ = tokio::time::sleep_until(deadline) => None, + _ = cancellation.cancelled() => { + cancel_sent = true; + let cancel_request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })) + .await; + tokio::time::timeout_at(deadline, &mut response_rx) + .await + .ok() + }, + }; + let result = match waited { + Some(Ok(Ok(RoutedResponse::Payload(payload)))) => { + environment_protocol::decode_payload(&payload).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("invalid WSL Worker mutation response: {error}"), + } + }) + } + Some(Ok(Ok(RoutedResponse::Control(Message::Error { code, phase, .. })))) => { + Err(AppError::ExecutionFailed { + message: format!("worker mutation failed during {phase}: {code}"), + }) + } + Some(Ok(Ok(RoutedResponse::Control(_)))) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a mutation request", + )), + Some(Ok(Err(error))) => Err(error), + Some(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + None => { + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled && !cancel_sent { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })) + .await; + } + Err(if cancellation.is_cancelled() { + AppError::MutationCancelled + } else { + AppError::WslCommandTimedOut + }) + } + }; + result.map_err(|error| MutationSessionError { + error, + accepted_resource_id: accepted_resource + .lock() + .expect("worker mutation accepted state lock poisoned") + .clone(), + }) + } + + pub(super) async fn request_payload_with_cancellation( + &self, + message: Message, + limit: Duration, + cancellation: CancellationSignal, + ) -> Result, AppError> { + match self + .request_response( + message, + limit, + ExpectedResponse::Payload, + Some(cancellation), + ) + .await? + { + RoutedResponse::Payload(payload) => Ok(payload), + RoutedResponse::Control(Message::Error { code, .. }) if code == "deadlineExceeded" => { + Err(AppError::WslCommandTimedOut) + } + RoutedResponse::Control(Message::Error { code, phase, .. }) => { + Err(AppError::ExecutionFailed { + message: format!("worker request failed during {phase}: {code}"), + }) + } + RoutedResponse::Control(_) => Err(unavailable( + &self.inner.distro_name, + "worker returned control for a payload request", + )), + } + } + + async fn request_response( + &self, + message: Message, + limit: Duration, + expected: ExpectedResponse, + cancellation: Option, + ) -> Result { + self.request_response_with_payload_limit( + message, + limit, + expected, + cancellation, + MAX_RESPONSE_TRANSFER_BYTES, + ) + .await + } + + async fn request_response_with_payload_limit( + &self, + message: Message, + limit: Duration, + expected: ExpectedResponse, + cancellation: Option, + max_payload_bytes: usize, + ) -> Result { + let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .insert( + request_id, + PendingResponse { + expected, + max_payload_bytes, + accepted_resource: None, + sender: response_tx, + }, + ); + if let Err(error) = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })) + .await + { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + + enum WaitResult { + Response(T), + TimedOut, + Cancelled, + } + let waited = if let Some(cancellation) = cancellation { + tokio::select! { + response = response_rx => WaitResult::Response(response), + _ = sleep(limit) => WaitResult::TimedOut, + _ = cancellation.cancelled() => WaitResult::Cancelled, + } + } else { + match tokio::time::timeout(limit, response_rx).await { + Ok(response) => WaitResult::Response(response), + Err(_) => WaitResult::TimedOut, + } + }; + match waited { + WaitResult::Response(Ok(response)) => response, + WaitResult::Response(Err(_)) => Err(unavailable( + &self.inner.distro_name, + "worker response router stopped", + )), + reason @ (WaitResult::TimedOut | WaitResult::Cancelled) => { + let was_cancelled = matches!(reason, WaitResult::Cancelled); + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let _ = self + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })) + .await; + } + if was_cancelled { + Err(AppError::MutationCancelled) + } else { + Err(AppError::WslCommandTimedOut) + } + } + } + } + + pub(super) fn closed_receiver(&self) -> watch::Receiver { + self.inner.closed.subscribe() + } + + async fn handshake( + &self, + expected: &super::WslSession, + build_id: &str, + ) -> Result<(), AppError> { + match self + .request( + Message::Handshake { + build_id: build_id.to_string(), + }, + HANDSHAKE_TIMEOUT, + ) + .await? + { + Message::HandshakeResult { + build_id: actual_build, + distro, + user, + uid, + home, + } if actual_build == build_id + && distro.eq_ignore_ascii_case(&expected.distro_name) + && user == expected.user + && uid == expected.uid + && home == expected.home => {} + Message::Error { code, phase, .. } => { + return Err(unavailable( + &expected.distro_name, + format!("worker handshake failed during {phase}: {code}"), + )); + } + _ => { + return Err(unavailable( + &expected.distro_name, + "worker identity does not match the WSL session", + )); + } + } + + match self + .request( + Message::ObservePath { + path: expected.home.clone(), + }, + HANDSHAKE_TIMEOUT, + ) + .await? + { + Message::PathObserved { + kind: PathKind::Directory | PathKind::SymlinkDirectory, + } => Ok(()), + Message::Error { code, phase, .. } => Err(unavailable( + &expected.distro_name, + format!("worker HOME observation failed during {phase}: {code}"), + )), + _ => Err(unavailable( + &expected.distro_name, + "WSL HOME is not an accessible directory", + )), + } + } +} + +impl Drop for WorkerSessionInner { + fn drop(&mut self) { + let _ = self.closed.send(true); + self.reader_task.abort(); + self.writer_task.abort(); + #[cfg(target_os = "windows")] + { + if let Some(task) = &self.stderr_task { + task.abort(); + } + if let Ok(mut child) = self.child.lock() { + if let Some(child) = child.as_mut() { + let _ = child.start_kill(); + } + } + } + } +} + +impl ResponseRoutes { + fn cancel_request(&mut self, request_id: u64) -> bool { + let cancelled = self.pending.remove(&request_id).is_some(); + if cancelled { + if let Some(transfer) = self + .active_transfer + .as_mut() + .filter(|transfer| transfer.owner_request_id == request_id) + { + transfer.payload = None; + } + } + cancelled + } + + fn remember_tombstone(&mut self, request_id: u64) { + if self.tombstone_set.insert(request_id) { + self.tombstones.push_back(request_id); + } + while self.tombstones.len() > TOMBSTONE_LIMIT { + if let Some(expired) = self.tombstones.pop_front() { + self.tombstone_set.remove(&expired); + } + } + } +} + +fn route_record(routes: &Mutex, record: WireRecord) -> Result<(), String> { + let mut routes = routes.lock().expect("worker response routes lock poisoned"); + match record { + WireRecord::Control(envelope) => route_control(&mut routes, envelope), + WireRecord::PayloadChunk { transfer_id, bytes } => { + route_payload_chunk(&mut routes, transfer_id, bytes) + } + } +} + +fn route_control(routes: &mut ResponseRoutes, envelope: Envelope) -> Result<(), String> { + match envelope.message { + Message::BeginTransfer { + transfer_id, + total_bytes, + sha256, + owner_request_id, + } => begin_transfer( + routes, + envelope.request_id, + transfer_id, + total_bytes, + sha256, + owner_request_id, + ), + Message::TransferCompleted { + transfer_id, + total_bytes, + sha256, + } => complete_transfer( + routes, + envelope.request_id, + transfer_id, + total_bytes, + sha256, + ), + Message::Progress { .. } => { + if routes.pending.contains_key(&envelope.request_id) + || routes.tombstone_set.contains(&envelope.request_id) + { + Ok(()) + } else { + Err("worker progress has no owning request".to_string()) + } + } + message => route_control_response(routes, envelope.request_id, message), + } +} + +fn begin_transfer( + routes: &mut ResponseRoutes, + envelope_request_id: u64, + transfer_id: u64, + total_bytes: u64, + sha256: String, + owner_request_id: u64, +) -> Result<(), String> { + if routes.active_transfer.is_some() || envelope_request_id != owner_request_id { + return Err("invalid worker transfer declaration".to_string()); + } + let payload = if let Some(pending) = routes.pending.get(&owner_request_id) { + if !matches!( + pending.expected, + ExpectedResponse::Payload | ExpectedResponse::Mutation + ) || total_bytes > pending.max_payload_bytes as u64 + { + return Err("worker payload does not match the pending response type".to_string()); + } + Some(Vec::with_capacity(total_bytes as usize)) + } else if routes.tombstone_set.contains(&owner_request_id) { + None + } else { + return Err("worker transfer has no owning request".to_string()); + }; + routes.active_transfer = Some(IncomingTransfer { + transfer_id, + owner_request_id, + total_bytes: total_bytes as usize, + expected_sha256: sha256, + received_bytes: 0, + hasher: Sha256::new(), + payload, + }); + Ok(()) +} + +fn route_payload_chunk( + routes: &mut ResponseRoutes, + transfer_id: u64, + bytes: Vec, +) -> Result<(), String> { + let transfer = routes + .active_transfer + .as_mut() + .ok_or_else(|| "worker payload has no active transfer".to_string())?; + if transfer.transfer_id != transfer_id + || bytes.len() > MAX_PAYLOAD_CHUNK_BYTES + || transfer.received_bytes.saturating_add(bytes.len()) > transfer.total_bytes + { + return Err("worker payload chunk exceeds its transfer boundary".to_string()); + } + transfer.received_bytes += bytes.len(); + transfer.hasher.update(&bytes); + if let Some(payload) = transfer.payload.as_mut() { + payload.extend_from_slice(&bytes); + } + Ok(()) +} + +fn complete_transfer( + routes: &mut ResponseRoutes, + envelope_request_id: u64, + transfer_id: u64, + total_bytes: u64, + sha256: String, +) -> Result<(), String> { + let transfer = routes + .active_transfer + .take() + .ok_or_else(|| "worker completed a transfer that is not active".to_string())?; + let actual_sha256 = format!("sha256:{:x}", transfer.hasher.finalize()); + if transfer.transfer_id != transfer_id + || transfer.owner_request_id != envelope_request_id + || transfer.total_bytes != total_bytes as usize + || transfer.received_bytes != transfer.total_bytes + || transfer.expected_sha256 != sha256 + || actual_sha256 != sha256 + { + return Err("worker transfer completion does not match its declaration".to_string()); + } + if let Some(payload) = transfer.payload { + let pending = routes + .pending + .remove(&transfer.owner_request_id) + .ok_or_else(|| "worker transfer owner is no longer pending".to_string())?; + if !matches!( + pending.expected, + ExpectedResponse::Payload | ExpectedResponse::Mutation + ) { + return Err("worker payload does not match the pending response type".to_string()); + } + let _ = pending.sender.send(Ok(RoutedResponse::Payload(payload))); + } else { + forget_tombstone(routes, transfer.owner_request_id); + } + Ok(()) +} + +fn route_control_response( + routes: &mut ResponseRoutes, + request_id: u64, + message: Message, +) -> Result<(), String> { + if let Message::MutationAccepted { resource_id } = &message { + let pending = routes + .pending + .get(&request_id) + .ok_or_else(|| "worker mutation acceptance has no owning request".to_string())?; + if pending.expected != ExpectedResponse::Mutation { + return Err( + "worker mutation acceptance does not match its pending request".to_string(), + ); + } + let accepted = pending + .accepted_resource + .as_ref() + .ok_or_else(|| "worker mutation acceptance state is unavailable".to_string())?; + let mut accepted = accepted + .lock() + .map_err(|_| "worker mutation acceptance state is poisoned".to_string())?; + if accepted.replace(resource_id.clone()).is_some() { + return Err("worker accepted one mutation more than once".to_string()); + } + return Ok(()); + } + if routes + .active_transfer + .as_ref() + .is_some_and(|transfer| transfer.owner_request_id == request_id) + { + return Err("worker control response interrupted an active transfer".to_string()); + } + if routes.tombstone_set.contains(&request_id) { + forget_tombstone(routes, request_id); + return Ok(()); + } + let pending = routes + .pending + .remove(&request_id) + .ok_or_else(|| "worker response has no owning request".to_string())?; + if pending.expected != ExpectedResponse::Control && !matches!(message, Message::Error { .. }) { + return Err("worker control does not match the pending response type".to_string()); + } + let _ = pending.sender.send(Ok(RoutedResponse::Control(message))); + Ok(()) +} + +fn forget_tombstone(routes: &mut ResponseRoutes, request_id: u64) { + routes.tombstone_set.remove(&request_id); + routes + .tombstones + .retain(|tombstone| *tombstone != request_id); +} + +fn fail_pending(routes: &Mutex, error: AppError) { + let pending = { + let mut routes = routes.lock().expect("worker response routes lock poisoned"); + routes.active_transfer = None; + std::mem::take(&mut routes.pending) + }; + for pending in pending.into_values() { + let _ = pending.sender.send(Err(error.clone())); + } +} + +fn unavailable(distro_name: &str, message: impl Into) -> AppError { + AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: message.into(), + } +} + +fn payload_control_error(distro_name: &str, code: &str, phase: &str) -> AppError { + match code { + "deadlineExceeded" => AppError::WslCommandTimedOut, + "pathUnavailable" if phase == "pathMapping" => AppError::CapabilityUnavailable { + capability: "wslPathMapping".to_string(), + path: None, + }, + "libraryRecoveryIncomplete" => AppError::LibraryRecoveryIncomplete { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL Skill Library recovery is incomplete".to_string(), + }, + _ => AppError::ExecutionFailed { + message: format!("worker request failed during {phase}: {code}"), + }, + } +} + +#[cfg(target_os = "windows")] +pub(super) async fn connect_worker( + session: &super::WslSession, + artifact_directory: &Path, +) -> Result { + let artifact = WorkerArtifact::load_from(artifact_directory) + .map_err(|message| unavailable(&session.distro_name, message))?; + deploy_worker(session, &artifact).await?; + + let worker_path = worker_path(&session.home); + let mut command = super::wsl_command(); + command + .args([ + "--distribution", + &session.distro_name, + "--user", + &session.user, + "--exec", + &worker_path, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = command + .spawn() + .map_err(|error| unavailable(&session.distro_name, error.to_string()))?; + let worker = WorkerSession::from_child(child, session.distro_name.clone())?; + worker.handshake(session, &artifact.build_id).await?; + Ok(worker) +} + +#[cfg(not(target_os = "windows"))] +pub(super) async fn connect_worker( + session: &super::WslSession, + _artifact_directory: &Path, +) -> Result { + Err(unavailable( + &session.distro_name, + "WSL worker is only available on Windows", + )) +} + +#[cfg(target_os = "windows")] +async fn deploy_worker( + session: &super::WslSession, + artifact: &WorkerArtifact, +) -> Result<(), AppError> { + let output = WslCommandRunner::run(WslCommandRequest { + session: session.clone(), + script: BOOTSTRAP_SCRIPT, + args: vec![artifact.build_id.clone()], + stdin: artifact.bytes.clone(), + timeout: WORKER_START_TIMEOUT, + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await?; + if output.exit_code != Some(0) { + return Err(unavailable( + &session.distro_name, + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn worker_path(home: &str) -> String { + if home == "/" { + "/.skill-deck/runtime/wsl-worker/current/worker".to_string() + } else { + format!( + "{}/.skill-deck/runtime/wsl-worker/current/worker", + home.trim_end_matches('/') + ) + } +} + +#[cfg(target_os = "windows")] +async fn drain_worker_stderr(mut stderr: tokio::process::ChildStderr, distro_name: &str) { + let mut retained = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) => break, + Ok(read) if retained.len() < WORKER_STDERR_LIMIT => { + let keep = read.min(WORKER_STDERR_LIMIT - retained.len()); + retained.extend_from_slice(&buffer[..keep]); + } + Ok(_) => {} + Err(error) => { + log::warn!("failed to drain WSL worker stderr for {distro_name}: {error}"); + return; + } + } + } + if !retained.is_empty() { + log::warn!( + "WSL worker stderr for {distro_name}: {}", + String::from_utf8_lossy(&retained).trim() + ); + } +} + +#[cfg(test)] +#[allow( + clippy::disallowed_methods, + reason = "Worker bootstrap 测试需要直接运行受控的 POSIX shell" +)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::path::Path; + #[cfg(unix)] + use std::process::{Command, Stdio}; + + use environment_protocol::{ + codec, decode, spawn_writer, Envelope, InspectionRequest, Message, WireRecord, + }; + use futures_util::StreamExt; + use sha2::{Digest, Sha256}; + use tokio::io::{duplex, split}; + use tokio::time::Duration; + use tokio_util::codec::FramedRead; + + use super::{payload_control_error, WorkerSession}; + use crate::core::mutation::CancellationSignal; + use crate::environment::wsl::WslSession; + + #[test] + fn library_recovery_payload_error_preserves_its_typed_contract() { + assert!(matches!( + payload_control_error("Ubuntu", "libraryRecoveryIncomplete", "libraryRead"), + crate::error::AppError::LibraryRecoveryIncomplete { + environment: crate::environment::types::EnvironmentRef::Wsl { distro_name }, + .. + } if distro_name == "Ubuntu" + )); + } + + #[test] + fn path_mapping_payload_error_preserves_its_typed_contract() { + assert_eq!( + payload_control_error("Ubuntu", "pathUnavailable", "pathMapping"), + crate::error::AppError::CapabilityUnavailable { + capability: "wslPathMapping".to_string(), + path: None, + } + ); + } + + #[test] + fn artifact_loader_rejects_bytes_that_do_not_match_the_manifest() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("worker"), b"worker").unwrap(); + fs::write( + directory.path().join("manifest.json"), + r#"{ + "buildId": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "sha256": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "target": "x86_64-unknown-linux-musl" + }"#, + ) + .unwrap(); + + let artifact = super::WorkerArtifact::load_from(directory.path()).unwrap(); + assert_eq!(artifact.bytes, b"worker"); + assert_eq!( + artifact.build_id, + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e" + ); + + fs::write(directory.path().join("worker"), b"damaged").unwrap(); + let error = super::WorkerArtifact::load_from(directory.path()).unwrap_err(); + assert!(error.contains("do not match"), "{error}"); + } + + #[test] + fn artifact_loader_does_not_search_sibling_directories() { + let root = tempfile::tempdir().unwrap(); + let sibling = root.path().join("sibling"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join("worker"), b"worker").unwrap(); + fs::write( + sibling.join("manifest.json"), + r#"{ + "buildId": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "sha256": "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + "target": "x86_64-unknown-linux-musl" + }"#, + ) + .unwrap(); + + assert!(super::WorkerArtifact::load_from(&root.path().join("explicit")).is_err()); + } + + #[cfg(unix)] + #[test] + fn bootstrap_script_verifies_and_atomically_installs_worker() { + use std::os::unix::fs::MetadataExt; + + let home = tempfile::tempdir().unwrap(); + assert!(run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + + let installed = home + .path() + .join(".skill-deck/runtime/wsl-worker/current/worker"); + assert_eq!(fs::read(&installed).unwrap(), b"worker"); + assert_eq!( + fs::metadata(&installed).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + let inode = fs::metadata(&installed).unwrap().ino(); + assert!(run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + assert_eq!(fs::metadata(&installed).unwrap().ino(), inode); + + assert!(!run_bootstrap( + home.path(), + b"damaged", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .success()); + assert_eq!(fs::read(&installed).unwrap(), b"worker"); + assert_eq!( + fs::read_dir(installed.parent().unwrap()).unwrap().count(), + 1 + ); + } + + #[cfg(unix)] + #[test] + fn bootstrap_switches_worker_builds_in_both_directions() { + let home = tempfile::tempdir().unwrap(); + let worker = home + .path() + .join(".skill-deck/runtime/wsl-worker/current/worker"); + for (bytes, digest) in [ + ( + b"worker-a".as_slice(), + "sha256:6a65e237ae44c42895b5049031466616173469f513b00240e9db90df66906a49", + ), + ( + b"worker-b".as_slice(), + "sha256:0482c4aea1af397e54200017866d778ce32e1daac473f2cd09a9159d2e69b6ef", + ), + ( + b"worker-a".as_slice(), + "sha256:6a65e237ae44c42895b5049031466616173469f513b00240e9db90df66906a49", + ), + ] { + assert!(run_bootstrap(home.path(), bytes, digest).success()); + assert_eq!(fs::read(&worker).unwrap(), bytes); + } + } + + #[cfg(unix)] + #[test] + fn bootstrap_rejects_non_file_targets_without_following_them() { + use std::os::unix::fs::symlink; + + for target_kind in ["directory", "symlink"] { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".skill-deck/runtime/wsl-worker/current"); + fs::create_dir_all(&directory).unwrap(); + let worker = directory.join("worker"); + if target_kind == "directory" { + fs::create_dir(&worker).unwrap(); + } else { + let external = home.path().join("external"); + fs::write(&external, b"external").unwrap(); + symlink(&external, &worker).unwrap(); + } + + assert!(!run_bootstrap( + home.path(), + b"worker", + "sha256:87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e", + ) + .success()); + assert!(fs::symlink_metadata(&worker).is_ok()); + assert_eq!( + fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .count(), + 1 + ); + } + } + + #[cfg(unix)] + fn run_bootstrap(home: &Path, bytes: &[u8], digest: &str) -> std::process::ExitStatus { + let mut child = Command::new("/bin/sh") + .args(["-c", super::BOOTSTRAP_SCRIPT, "--", digest]) + .env("HOME", home) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(bytes).unwrap(); + child.wait().unwrap() + } + + #[tokio::test] + async fn concurrent_requests_receive_responses_by_request_id() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let first = next_envelope(&mut reader).await; + let second = next_envelope(&mut reader).await; + for request in [second, first] { + writer + .send_control(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + } + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let first = session.request( + Message::ObservePath { + path: "/home/alice".to_string(), + }, + Duration::from_secs(1), + ); + let second = session.request( + Message::ObservePath { + path: "/tmp".to_string(), + }, + Duration::from_secs(1), + ); + let (first, second) = tokio::join!(first, second); + + assert!(matches!(first.unwrap(), Message::PathObserved { .. })); + assert!(matches!(second.unwrap(), Message::PathObserved { .. })); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn payload_response_is_bounded_reassembled_and_verified() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; 3073]; + let expected = payload.clone(); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + assert!(matches!(request.message, Message::InspectFilesystem { .. })); + writer + .send_transfer(request.request_id, 91, &payload) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let actual = session + .request_payload(inspection_request(), Duration::from_secs(1)) + .await + .unwrap(); + + assert_eq!(actual, expected); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn payload_response_uses_the_pending_requests_specific_limit() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + writer + .send_transfer(request.request_id, 94, b"four") + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .request_payload_with_limit(inspection_request(), Duration::from_secs(1), 3, None) + .await + .unwrap_err(); + + assert!(matches!( + error, + crate::error::AppError::EnvironmentUnavailable { .. } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn prepared_inbound_transfer_uses_a_fresh_owner_request() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + assert!(matches!( + declaration.message, + Message::BeginTransfer { + transfer_id: 700, + owner_request_id: owner, + .. + } if owner == owner_request_id + )); + assert!(matches!( + decode(&reader.next().await.unwrap().unwrap()).unwrap(), + WireRecord::PayloadChunk { + transfer_id: 700, + .. + } + )); + let completion = next_envelope(&mut reader).await; + assert_eq!(completion.request_id, owner_request_id); + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::PayloadBlobUploaded { + upload_id: 7, + blob_id: "a".repeat(64), + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let response = session + .send_prepared_transfer(700, b"blob", 16, Duration::from_secs(1)) + .await + .unwrap(); + + assert!(matches!( + response, + Message::PayloadBlobUploaded { upload_id: 7, .. } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn mutation_acceptance_keeps_the_pending_route_until_terminal_payload() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + assert!(matches!(declaration.message, Message::BeginTransfer { .. })); + assert!(matches!( + decode(&reader.next().await.unwrap().unwrap()).unwrap(), + WireRecord::PayloadChunk { .. } + )); + let _completion = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-1".to_string(), + }, + })) + .await + .unwrap(); + let outcome = environment_protocol::encode_payload( + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .unwrap(); + writer + .send_transfer(owner_request_id, 701, &outcome) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let outcome = session + .send_prepared_mutation( + 700, + b"request", + CancellationSignal::default(), + Duration::from_secs(1), + ) + .await + .map_err(|error| error.error) + .unwrap(); + + assert_eq!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn mutation_disconnect_reports_whether_the_worker_accepted_it() { + for accepted in [false, true] { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = + WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + let _chunk = reader.next().await; + let _completion = next_envelope(&mut reader).await; + if accepted { + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-accepted".to_string(), + }, + })) + .await + .unwrap(); + } + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .send_prepared_mutation( + 710, + b"request", + CancellationSignal::default(), + Duration::from_secs(1), + ) + .await + .unwrap_err(); + + assert_eq!( + error.accepted_resource_id.as_deref(), + accepted.then_some("resource-accepted") + ); + server_task.await.unwrap(); + } + } + + #[tokio::test] + async fn mutation_cancellation_waits_for_the_worker_terminal_outcome() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let declaration = next_envelope(&mut reader).await; + let owner_request_id = declaration.request_id; + let _chunk = reader.next().await; + let _completion = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: owner_request_id, + message: Message::MutationAccepted { + resource_id: "resource-cancelled".to_string(), + }, + })) + .await + .unwrap(); + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == owner_request_id + )); + let outcome = environment_protocol::encode_payload( + &environment_protocol::MutationUnitOutcome::Cancelled, + ) + .unwrap(); + writer + .send_transfer(owner_request_id, 712, &outcome) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + let cancellation = CancellationSignal::default(); + let cancellation_task = { + let cancellation = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + cancellation.cancel(); + }) + }; + + let outcome = session + .send_prepared_mutation(711, b"request", cancellation, Duration::from_secs(1)) + .await + .map_err(|error| error.error) + .unwrap(); + + assert_eq!( + outcome, + environment_protocol::MutationUnitOutcome::Cancelled + ); + cancellation_task.await.unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn mismatched_payload_digest_invalidates_the_session() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let mut closed = session.closed_receiver(); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let request = next_envelope(&mut reader).await; + writer + .send_binary(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::BeginTransfer { + transfer_id: 92, + total_bytes: 3, + sha256: "sha256:incorrect".to_string(), + owner_request_id: request.request_id, + }, + })) + .await + .unwrap(); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 92, + bytes: b"abc".to_vec(), + }) + .await + .unwrap(); + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: request.request_id, + message: Message::TransferCompleted { + transfer_id: 92, + total_bytes: 3, + sha256: "sha256:incorrect".to_string(), + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + let error = session + .request_payload(inspection_request(), Duration::from_secs(1)) + .await + .unwrap_err(); + + assert!(matches!( + error, + crate::error::AppError::EnvironmentUnavailable { .. } + )); + tokio::time::timeout(Duration::from_secs(1), closed.changed()) + .await + .unwrap() + .unwrap(); + assert!(*closed.borrow()); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn timed_out_payload_transfer_is_drained_before_the_session_is_reused() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let expired = next_envelope(&mut reader).await; + let digest = format!("sha256:{:x}", Sha256::digest(b"abcdef")); + writer + .send_binary(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::BeginTransfer { + transfer_id: 93, + total_bytes: 6, + sha256: digest.clone(), + owner_request_id: expired.request_id, + }, + })) + .await + .unwrap(); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 93, + bytes: b"abc".to_vec(), + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == expired.request_id + )); + writer + .send_binary(WireRecord::PayloadChunk { + transfer_id: 93, + bytes: b"def".to_vec(), + }) + .await + .unwrap(); + writer + .send_binary_barrier(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::TransferCompleted { + transfer_id: 93, + total_bytes: 6, + sha256: digest, + }, + })) + .await + .unwrap(); + let current = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: current.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + assert_eq!( + session + .request_payload(inspection_request(), Duration::from_millis(10)) + .await + .unwrap_err(), + crate::error::AppError::WslCommandTimedOut, + ); + assert!(matches!( + session + .request( + Message::ObservePath { + path: "/current".to_string(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(), + Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn timed_out_request_is_cancelled_and_its_late_response_is_ignored() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let expired = next_envelope(&mut reader).await; + let cancel = next_envelope(&mut reader).await; + assert!(matches!( + cancel.message, + Message::Cancel { target_request_id } if target_request_id == expired.request_id + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: expired.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Missing, + }, + })) + .await + .unwrap(); + let current = next_envelope(&mut reader).await; + writer + .send_control(WireRecord::Control(Envelope { + request_id: current.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + + assert_eq!( + session + .request( + Message::ObservePath { + path: "/expired".to_string(), + }, + Duration::from_millis(10), + ) + .await + .unwrap_err(), + crate::error::AppError::WslCommandTimedOut, + ); + assert!(matches!( + session + .request( + Message::ObservePath { + path: "/current".to_string(), + }, + Duration::from_secs(1), + ) + .await + .unwrap(), + Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + } + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn handshake_confirms_identity_and_home_directory() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let server_task = tokio::spawn(async move { + let (server_reader, server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let (writer, writer_task) = spawn_writer(server_writer); + let handshake = next_envelope(&mut reader).await; + assert!(matches!( + handshake.message, + Message::Handshake { ref build_id } if build_id == "sha256:build" + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: handshake.request_id, + message: Message::HandshakeResult { + build_id: "sha256:build".to_string(), + distro: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: "/home/alice".to_string(), + }, + })) + .await + .unwrap(); + let home = next_envelope(&mut reader).await; + assert!(matches!( + home.message, + Message::ObservePath { ref path } if path == "/home/alice" + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: home.request_id, + message: Message::PathObserved { + kind: environment_protocol::PathKind::Directory, + }, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + }); + let expected = WslSession { + distro_name: "Ubuntu".to_string(), + user: "alice".to_string(), + uid: 1000, + home: "/home/alice".to_string(), + xdg_state_home: None, + config_home: "/home/alice/.config".to_string(), + environment: Default::default(), + runtime_generation: 0, + }; + + session.handshake(&expected, "sha256:build").await.unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn closing_worker_output_notifies_the_session_owner() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let mut closed = session.closed_receiver(); + + drop(server); + + tokio::time::timeout(Duration::from_secs(1), closed.changed()) + .await + .unwrap() + .unwrap(); + assert!(*closed.borrow()); + } + + async fn next_envelope( + reader: &mut FramedRead, + ) -> Envelope + where + R: tokio::io::AsyncRead + Unpin, + { + match decode(&reader.next().await.unwrap().unwrap()).unwrap() { + WireRecord::Control(envelope) => envelope, + WireRecord::PayloadChunk { .. } => panic!("unexpected payload chunk"), + } + } + + fn inspection_request() -> Message { + Message::InspectFilesystem { + request: InspectionRequest { + roots: Vec::new(), + per_file_limit: 1, + aggregate_limit: 1, + deadline_millis: 1_000, + }, + } + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 0515fdfb..657ef768 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -320,7 +320,10 @@ pub enum AppError { WslOutputLimitExceeded { stream: String, limit: u32 }, #[error("WSL command failed with exit code {exit_code:?}: {stderr}")] - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[allow( + dead_code, + reason = "保留已发布的 AppError IPC 形状;WSL 业务 shell 已由 typed Worker request 替代" + )] WslCommandFailed { #[serde(rename = "exitCode")] exit_code: Option, diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index a4b0a430..90accfc1 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -156,6 +156,7 @@ impl RuntimeServiceGraph { recovery_root: std::path::PathBuf, library_root: std::path::PathBuf, agents: ManagedAgentRegistry, + worker_artifact_directory: Option, ) -> Result { let config = crate::core::read_config()?; let wsl_integration_enabled = cfg!(target_os = "windows") && config.wsl_integration_enabled; @@ -163,9 +164,10 @@ impl RuntimeServiceGraph { let http = network_services.http_client(); let download = download::RuntimeDownloadAccess::new(http.clone()); let git_source = network_services.git_source(); - let wsl = Arc::new(WslRuntime::new_with_support( + let wsl = Arc::new(WslRuntime::new_with_worker_artifact_directory( cfg!(target_os = "windows"), wsl_integration_enabled, + worker_artifact_directory, )); let connection_probe = network_connection::RuntimeNetworkConnectionProbe::new(wsl.clone()); let (payloads, native_payload_storage) = build_payload_session_manager(payload_cache_root)?; diff --git a/src-tauri/src/runtime/network_connection.rs b/src-tauri/src/runtime/network_connection.rs index 13edb36b..e9633c18 100644 --- a/src-tauri/src/runtime/network_connection.rs +++ b/src-tauri/src/runtime/network_connection.rs @@ -138,8 +138,13 @@ async fn test_wsl_git( ) -> ProxyConnectionProbe { let started_at = Instant::now(); let deadline = started_at + TEST_TIMEOUT; - let probe = wsl.with_session(&distro, |session| { + let workspace = match wsl.workspace(&distro) { + Ok(workspace) => workspace, + Err(error) => return git_probe(started_at, Err(error)), + }; + let probe = wsl.with_session(&distro, |_session| { let proxy = settings.wsl_git_proxy(&distro, TEST_GIT_URL); + let workspace = workspace.clone(); async move { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -148,7 +153,7 @@ async fn test_wsl_git( }); } probe_wsl_git_connection( - &session, + &workspace, TEST_GIT_URL, proxy, remaining, From dde90147ae62cb9d776c886c8da38dead088f61f Mon Sep 17 00:00:00 2001 From: Hccake Date: Thu, 3 Sep 2026 20:58:13 +0800 Subject: [PATCH 04/27] refactor(wsl): route reads and sources through the worker --- src-tauri/src/application/agents.rs | 94 +- .../application/installed_skill_payload.rs | 8 +- src-tauri/src/application/payload_session.rs | 17 +- src-tauri/src/application/skill_read.rs | 74 +- src-tauri/src/application/skills.rs | 62 +- src-tauri/src/core/paths.rs | 6 +- src-tauri/src/core/source_parser.rs | 19 +- .../src/environment/agent_environment.rs | 248 ++- src-tauri/src/environment/context_resolver.rs | 5 +- .../src/environment/directory_inspection.rs | 34 +- .../src/environment/native/inspection.rs | 64 +- src-tauri/src/environment/path_mapping.rs | 16 +- .../src/environment/wsl/operations/acquire.rs | 1745 +++++------------ .../wsl/operations/content_manifest.rs | 348 +--- .../wsl/operations/directory_count.rs | 209 +- .../src/environment/wsl/operations/entry.rs | 322 ++- .../src/environment/wsl/operations/eve.rs | 70 - .../environment/wsl/operations/inspection.rs | 342 ++-- .../src/environment/wsl/operations/path.rs | 158 -- .../wsl/operations/path_metadata.rs | 171 +- .../environment/wsl/operations/projection.rs | 259 +-- .../src/environment/wsl/operations/scan.rs | 694 ++----- .../wsl/operations/skill_content.rs | 46 +- .../wsl/operations/source_acquisition.rs | 1151 +++-------- .../src/environment/wsl/scripts/acquire.sh | 523 ----- .../wsl/scripts/content-manifest.sh | 81 - .../wsl/scripts/directory-count.sh | 42 - .../src/environment/wsl/scripts/entry.sh | 52 - src-tauri/src/environment/wsl/scripts/eve.sh | 25 - .../environment/wsl/scripts/path-metadata.sh | 64 - src-tauri/src/environment/wsl/scripts/path.sh | 15 - .../src/environment/wsl/scripts/projection.sh | 45 - src-tauri/src/environment/wsl/scripts/scan.sh | 136 -- .../environment/wsl/scripts/skill-content.sh | 25 - .../wsl/scripts/source-acquisition.sh | 94 - src-tauri/src/runtime/copy_service.rs | 16 +- src-tauri/src/runtime/planning_facts.rs | 9 +- src-tauri/src/runtime/resource_service.rs | 20 +- src-tauri/src/runtime/wsl_source.rs | 13 +- 39 files changed, 2050 insertions(+), 5272 deletions(-) delete mode 100644 src-tauri/src/environment/wsl/operations/eve.rs delete mode 100644 src-tauri/src/environment/wsl/operations/path.rs delete mode 100644 src-tauri/src/environment/wsl/scripts/acquire.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/content-manifest.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/directory-count.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/entry.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/eve.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/path-metadata.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/path.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/projection.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/scan.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/skill-content.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/source-acquisition.sh diff --git a/src-tauri/src/application/agents.rs b/src-tauri/src/application/agents.rs index 05709cee..d7772e25 100644 --- a/src-tauri/src/application/agents.rs +++ b/src-tauri/src/application/agents.rs @@ -14,19 +14,18 @@ use crate::core::agent_registry::{AgentRegistry, AgentRegistrySnapshot}; use crate::core::agent_settings::{AgentSettingsSnapshot, AgentStorageIssue, CustomAgentRecord}; use crate::core::custom_agent_repository::CustomAgentRepository; use crate::core::mutation::MutationKind; -use crate::core::paths::PATHS; use crate::environment::agent_environment::{ - AgentEnvironmentResolver, AgentRuntimeSnapshot, DetectionReason, DirectoryPresenceState, - EnvironmentContext, ResolvedAgent, ResolvedAgentScope, + native_environment_context, wsl_environment_context, AgentEnvironmentResolver, + AgentRuntimeSnapshot, DetectionReason, DirectoryPresenceState, EnvironmentContext, + ResolvedAgent, ResolvedAgentScope, }; -use crate::environment::context_resolver::{ContextResolver, ResolvedContext}; +use crate::environment::context_resolver::ContextResolver; use crate::environment::directory_inspection::{inspect_native, inspect_wsl, DirectoryInspection}; -use crate::environment::types::{EnvironmentRef, EnvironmentStatus, SkillLocationRef}; -use crate::environment::wsl::{WslRuntime, WslSession}; +use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::models::Scope; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use specta::Type; const CUSTOM_AGENT_STORAGE_UNAVAILABLE_CODE: &str = "customAgentStorageUnavailable"; @@ -471,17 +470,22 @@ pub async fn list_agents( let context = retry_context.clone(); async move { let resolved = - ContextResolver::resolve_wsl(context.clone(), &session).await?; + ContextResolver::resolve_wsl(context.clone(), &session, &workspace) + .await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); + let metadata_workspace = workspace.clone(); let environment = wsl_environment_context(&resolved, session.clone(), workspace); let snapshot = service.registry_snapshot(true); - AgentEnvironmentResolver::from_active_wsl_session(environment, session) - .resolve_registry(&snapshot, project_path.as_deref()) - .await + AgentEnvironmentResolver::from_active_wsl_workspace( + environment, + metadata_workspace, + ) + .resolve_registry(&snapshot, project_path.as_deref()) + .await } }) .await @@ -563,17 +567,19 @@ pub async fn validate_custom_agent_draft( let preview_snapshot = preview_snapshot.clone(); let draft_id = draft_id.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); + let metadata_workspace = workspace.clone(); resolve_custom_agent_preview_with_resolver( &preview_snapshot, &draft_id, - AgentEnvironmentResolver::from_active_wsl_session( + AgentEnvironmentResolver::from_active_wsl_workspace( wsl_environment_context(&resolved, session.clone(), workspace), - session, + metadata_workspace, ), project_path.as_deref(), ) @@ -698,19 +704,21 @@ pub async fn preview_custom_agent_delete( async move { let agent_id = definition.id.clone(); let display_name = definition.display_name.clone(); - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let project_path = resolved .project .as_ref() .map(|project| project.native_path.clone()); - let runtime = AgentEnvironmentResolver::from_active_wsl_session( - wsl_environment_context(&resolved, session.clone(), workspace), - session.clone(), + let metadata_workspace = workspace.clone(); + let runtime = AgentEnvironmentResolver::from_active_wsl_workspace( + wsl_environment_context(&resolved, session.clone(), workspace.clone()), + metadata_workspace, ) .resolve_registry(&snapshot, project_path.as_deref()) .await?; let paths = delete_impact_resolved_paths(&runtime, &agent_id); - let inspections = inspect_wsl(&session, &paths).await?; + let inspections = inspect_wsl(&workspace, &paths).await?; Ok(build_delete_impact( &runtime, agent_id, @@ -1077,52 +1085,6 @@ fn delete_preview_snapshot( }) } -fn native_environment_context(resolved: &ResolvedContext) -> EnvironmentContext { - let environment_variables = std::env::vars().collect::>(); - let home = resolved.home.native_path.clone(); - let config_home = PATHS.config_home.to_string_lossy().to_string(); - let revision = environment_revision( - "native", - &(home.clone(), config_home.clone(), &environment_variables), - ); - EnvironmentContext { - environment: EnvironmentRef::Native, - home, - config_home, - environment_variables, - availability: EnvironmentStatus::Available, - revision, - wsl_workspace: None, - } -} - -fn wsl_environment_context( - resolved: &ResolvedContext, - session: WslSession, - workspace: crate::environment::wsl::WslWorkspace, -) -> EnvironmentContext { - let revision = environment_revision("wsl", &session); - EnvironmentContext { - environment: resolved.context.environment.clone(), - home: session.home.clone(), - config_home: session.config_home.clone(), - environment_variables: session.environment.clone(), - availability: EnvironmentStatus::Available, - revision, - wsl_workspace: Some(workspace), - } -} - -fn environment_revision(value_kind: &str, value: &impl Serialize) -> String { - let mut hasher = Sha256::new(); - hasher.update(value_kind.as_bytes()); - hasher.update( - serde_json::to_vec(value) - .expect("environment revision inputs must serialize deterministically"), - ); - format!("{:x}", hasher.finalize()) -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -1140,7 +1102,9 @@ mod tests { use crate::environment::agent_environment::{ AgentEnvironmentResolver, DetectionState, EnvironmentContext, }; + use crate::environment::context_resolver::ResolvedContext; use crate::environment::types::{EnvironmentStatus, ResourceLocator, SkillLocation}; + use crate::environment::wsl::WslSession; use serde_json::json; #[test] diff --git a/src-tauri/src/application/installed_skill_payload.rs b/src-tauri/src/application/installed_skill_payload.rs index daccec8a..f8d2782e 100644 --- a/src-tauri/src/application/installed_skill_payload.rs +++ b/src-tauri/src/application/installed_skill_payload.rs @@ -5,12 +5,10 @@ use crate::application::installed_skill_resolver::InstalledSkillResolver; use crate::application::mutation::plan::stable_digest; use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySourceDescriptor, DiscoverySourceLocation, - PayloadPlanningMetadata, PayloadSessionManager, PayloadSessionStorage, PayloadStorageKey, - RetainedDiscoverySource, + PayloadPlanningMetadata, PayloadSessionManager, PayloadStorageKey, RetainedDiscoverySource, }; use crate::environment::planning::{ResolvedTargetFact, TargetEntryKind}; use crate::environment::types::{same_environment_identity, EnvironmentRef, SkillLocationRef}; -use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; use crate::environment::wsl::WslRuntime; use crate::error::AppError; @@ -59,7 +57,7 @@ impl InstalledSkillPayloadAcquirer { let workspace = self.environments.workspace(distro_name)?; let standard_path = standard.destination.native_path.clone(); let skill_name = skill_name.to_string(); - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = workspace.payload_storage(); let retained = RetainedDiscoverySource::new( DiscoverySourceLocation::WslNative { distro_name: distro_name.clone(), @@ -117,7 +115,7 @@ impl InstalledSkillPayloadAcquirer { .payload_root_hash), EnvironmentRef::Wsl { distro_name } => { let workspace = self.environments.workspace(distro_name)?; - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = workspace.payload_storage(); let session_id = format!("copy-source-check-{}", uuid::Uuid::new_v4().simple()); let key = PayloadStorageKey::new(&session_id, skill_name); let acquired = storage diff --git a/src-tauri/src/application/payload_session.rs b/src-tauri/src/application/payload_session.rs index f513e1bc..7054939c 100644 --- a/src-tauri/src/application/payload_session.rs +++ b/src-tauri/src/application/payload_session.rs @@ -236,7 +236,8 @@ pub enum PayloadLocalSource { }, WslManaged { distro_name: String, - payload_root: String, + worker_generation: u64, + worker_payload_id: u64, }, } @@ -324,6 +325,20 @@ pub trait PayloadSessionStorage: Send + Sync { }) } + fn acquire_from_path<'a>( + &'a self, + _key: &'a PayloadStorageKey, + _source_root: &'a str, + _cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "backendPathPayloadAcquisition".to_string(), + path: None, + }) + }) + } + fn source_metadata_fingerprint<'a>( &'a self, _source_root: &'a str, diff --git a/src-tauri/src/application/skill_read.rs b/src-tauri/src/application/skill_read.rs index 6829dd0d..1def1e51 100644 --- a/src-tauri/src/application/skill_read.rs +++ b/src-tauri/src/application/skill_read.rs @@ -10,6 +10,7 @@ use crate::core::agent_availability::{ }; use crate::core::agent_definition::{AgentAdapter, AgentId}; use crate::core::skill::{InstalledSkill, InstalledSkillLocation, SkillFrontmatter}; +use crate::environment::agent_environment::inspect_eve_project; use crate::environment::agent_environment::{AgentRuntimeSnapshot, DetectionState, ResolvedAgent}; use crate::environment::context_resolver::ResolvedContext; use crate::environment::inspection::{ @@ -19,8 +20,7 @@ use crate::environment::runtime::ContextSnapshotRevision; use crate::environment::types::{ same_environment_identity, EnvironmentRef, ResourceLocator, SkillLocation, }; -use crate::environment::wsl::operations::eve::inspect_eve_project; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; use crate::models::{AgentSkillPresence, SkillInstallTargetInfo}; @@ -65,7 +65,7 @@ struct SkillCandidate { pub async fn discover_eve_skill_targets( context: &ResolvedContext, runtime: &AgentRuntimeSnapshot, - wsl_session: Option<&WslSession>, + wsl_workspace: Option<&WslWorkspace>, ) -> Result, AppError> { let Some(project) = context.project.as_ref() else { return Ok(Vec::new()); @@ -77,7 +77,7 @@ pub async fn discover_eve_skill_targets( else { return Ok(Vec::new()); }; - match (&context.context.environment, wsl_session) { + match (&context.context.environment, wsl_workspace) { (EnvironmentRef::Native, None) => Ok(crate::core::eve::eve_install_targets_for_project( &project.native_path, ) @@ -90,8 +90,8 @@ pub async fn discover_eve_skill_targets( path: target.path, }) .collect()), - (EnvironmentRef::Wsl { .. }, Some(session)) => { - let inspected = inspect_eve_project(session, &project.native_path).await?; + (EnvironmentRef::Wsl { .. }, Some(workspace)) => { + let inspected = inspect_eve_project(workspace, &project.native_path).await?; if !inspected.has_eve { return Ok(Vec::new()); } @@ -252,6 +252,11 @@ pub fn project_skill_snapshot( let Some(relative_dir) = fact.relative_path.strip_suffix("/SKILL.md") else { continue; }; + if relative_dir.starts_with(".skill-deck-stage-") + || relative_dir.starts_with(".skill-deck-backup-") + { + continue; + } if fact.truncated || fact.error_code.is_some() { continue; } @@ -760,6 +765,63 @@ mod tests { assert_eq!(result.agents[0].definition.id.as_str(), "custom-both"); } + #[test] + fn skill_snapshot_ignores_managed_stage_and_backup_directories() { + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }; + let context = context(environment.clone()); + let runtime = runtime(environment.clone()); + let plan = build_skill_read_plan(&context, &runtime, &[]).unwrap(); + let canonical_index = root_index(&plan, "/work/app/.agents/skills"); + let mut facts = vec![root_fact(canonical_index)]; + for (directory, name) in [ + ("toolkit", "toolkit"), + (".skill-deck-stage-operation-000000", "staged"), + (".skill-deck-backup-operation-000000", "backup"), + ] { + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: directory.to_string(), + kind: FilesystemEntryKind::Directory, + resolved_target: None, + frontmatter_bytes: Vec::new(), + truncated: false, + error_code: None, + }); + facts.push(RawPathFact { + root_index: canonical_index, + relative_path: format!("{directory}/SKILL.md"), + kind: FilesystemEntryKind::File, + resolved_target: None, + frontmatter_bytes: format!("---\nname: {name}\ndescription: Test\n---\n") + .into_bytes(), + truncated: false, + error_code: None, + }); + } + + let result = project_skill_snapshot( + &plan, + RawFilesystemSnapshot { + environment, + facts, + total_content_bytes: 0, + }, + &runtime, + ) + .unwrap(); + + assert_eq!( + result + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>(), + vec!["toolkit"] + ); + } + #[cfg(unix)] #[tokio::test] async fn native_private_directory_symlink_is_included_in_associated_agents() { diff --git a/src-tauri/src/application/skills.rs b/src-tauri/src/application/skills.rs index 7fbf91d5..c1ad6234 100644 --- a/src-tauri/src/application/skills.rs +++ b/src-tauri/src/application/skills.rs @@ -16,7 +16,6 @@ use crate::environment::lock_io::EnvironmentLockIo; use crate::environment::native::inspection::NativeInspector; use crate::environment::read_service::ReadService; use crate::environment::types::{EnvironmentRef, ResourceLocator, SkillLocationRef}; -use crate::environment::wsl::operations::inspection::WslInspector; use crate::environment::wsl::WslRuntime; use crate::error::AppError; @@ -356,37 +355,63 @@ pub async fn list_skills( EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let retry_context = context.clone(); - environment_registry + let retry_runtime = runtime.clone(); + let workspace = environment_registry.workspace(&distro_name)?; + let (resolved, plan) = environment_registry .with_session_retry(&distro_name, move |session| { let context = retry_context.clone(); - let runtime = runtime.clone(); + let runtime = retry_runtime.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; let eve_targets = - discover_eve_skill_targets(&resolved, &runtime, Some(&session)).await?; + discover_eve_skill_targets(&resolved, &runtime, Some(&workspace)) + .await?; let plan = build_skill_read_plan(&resolved, &runtime, &eve_targets)?; - let read_service = - ReadService::new(vec![Arc::new(WslInspector::new(session.clone()))]); - let snapshot = read_service.execute(&plan.read_plan).await?; - let result = project_skill_snapshot(&plan, snapshot, &runtime)?; - enrich_from_context_lock( - result, + Ok((resolved, plan)) + } + }) + .await?; + let workspace = environment_registry.workspace(&distro_name)?; + let read_service = ReadService::new(vec![workspace.filesystem_inspector()]); + let snapshot = read_service.execute(&plan.read_plan).await?; + let result = project_skill_snapshot(&plan, snapshot, &runtime)?; + let retry_resolved = resolved.clone(); + let (lock_bytes, lock_kind) = environment_registry + .with_session_retry(&distro_name, move |session| { + let resolved = retry_resolved.clone(); + let workspace = workspace.clone(); + async move { + read_context_lock( &resolved, - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, ) .await } }) - .await + .await?; + enrich_from_lock_bytes(result, &resolved, lock_bytes, lock_kind) } } } async fn enrich_from_context_lock( - mut result: ListSkillsResult, + result: ListSkillsResult, context: &ResolvedContext, lock_io: EnvironmentLockIo, ) -> Result { + let (lock_bytes, lock_kind) = read_context_lock(context, lock_io).await?; + enrich_from_lock_bytes(result, context, lock_bytes, lock_kind) +} + +async fn read_context_lock( + context: &ResolvedContext, + lock_io: EnvironmentLockIo, +) -> Result<(Option>, LockKind), AppError> { let mut lock_bytes = lock_io.read_optional(&context.lock).await.ok().flatten(); let mut lock_kind = if context.project.is_some() { LockKind::Project @@ -408,6 +433,15 @@ async fn enrich_from_context_lock( } } } + Ok((lock_bytes, lock_kind)) +} + +fn enrich_from_lock_bytes( + mut result: ListSkillsResult, + context: &ResolvedContext, + lock_bytes: Option>, + lock_kind: LockKind, +) -> Result { result.skills = enrich_environment_skills_from_lock_at( result.skills, lock_bytes.as_deref(), diff --git a/src-tauri/src/core/paths.rs b/src-tauri/src/core/paths.rs index 56ee2856..88d3f595 100644 --- a/src-tauri/src/core/paths.rs +++ b/src-tauri/src/core/paths.rs @@ -1,12 +1,12 @@ // 路径工具函数 // 对应 CLI: agents.ts 顶层常量 (home, configHome) -use once_cell::sync::Lazy; use std::path::PathBuf; +use std::sync::LazyLock; /// 路径上下文(与 CLI 顶层常量对应) -/// 使用 Lazy 单例,只初始化一次 -pub static PATHS: Lazy = Lazy::new(PathContext::new); +/// 使用 LazyLock 单例,只初始化一次 +pub static PATHS: LazyLock = LazyLock::new(PathContext::new); /// 路径上下文 /// 对应 CLI: agents.ts 第 7-11 行 diff --git a/src-tauri/src/core/source_parser.rs b/src-tauri/src/core/source_parser.rs index 036a2b47..02619b74 100644 --- a/src-tauri/src/core/source_parser.rs +++ b/src-tauri/src/core/source_parser.rs @@ -16,9 +16,9 @@ use crate::error::AppError; use crate::models::{ParsedSource, SourceType}; -use once_cell::sync::Lazy; use regex::Regex; use std::path::Path; +use std::sync::LazyLock; use url::Url; /// Source 别名映射 @@ -48,13 +48,13 @@ struct FragmentRefResult { } // Lazy regex patterns for looks_like_git_source -static GITHUB_PATH_RE: Lazy = - Lazy::new(|| Regex::new(r"^/[^/]+/[^/]+(?:\.git)?(?:/tree/[^/]+(?:/.*)?)?/?$").unwrap()); -static GITLAB_PATH_RE: Lazy = - Lazy::new(|| Regex::new(r"^/.+?/[^/]+(?:\.git)?(?:/-/tree/[^/]+(?:/.*)?)?/?$").unwrap()); -static GIT_URL_RE: Lazy = Lazy::new(|| Regex::new(r"\.git(?:$|\?|/)").unwrap()); -static SHORTHAND_RE: Lazy = - Lazy::new(|| Regex::new(r"^[^/]+/[^/]+(?:/(.+)|@(.+))?$").unwrap()); +static GITHUB_PATH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^/[^/]+/[^/]+(?:\.git)?(?:/tree/[^/]+(?:/.*)?)?/?$").unwrap()); +static GITLAB_PATH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^/.+?/[^/]+(?:\.git)?(?:/-/tree/[^/]+(?:/.*)?)?/?$").unwrap()); +static GIT_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"\.git(?:$|\?|/)").unwrap()); +static SHORTHAND_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[^/]+/[^/]+(?:/(.+)|@(.+))?$").unwrap()); /// 判断输入是否看起来像 git 来源(用于决定是否提取 #fragment 作为分支引用) /// 只有 git-like 来源才应将 # 后的内容解释为分支 ref @@ -529,7 +529,8 @@ pub fn get_owner_repo(parsed: &ParsedSource) -> Option { } SourceType::Git => { // git@host:owner/repo.git → owner/repo - static SSH_RE: Lazy = Lazy::new(|| Regex::new(r"^git@[^:]+:(.+)$").unwrap()); + static SSH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^git@[^:]+:(.+)$").unwrap()); if let Some(caps) = SSH_RE.captures(&parsed.url) { let path = caps[1].trim_end_matches(".git"); if path.contains('/') { diff --git a/src-tauri/src/environment/agent_environment.rs b/src-tauri/src/environment/agent_environment.rs index 132caba1..5d2e1933 100644 --- a/src-tauri/src/environment/agent_environment.rs +++ b/src-tauri/src/environment/agent_environment.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use std::sync::Mutex; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use specta::Type; use crate::core::agent_definition::{ @@ -14,16 +15,15 @@ use crate::core::agent_definition::{ ScopeDefinition, }; use crate::core::agent_registry::AgentRegistrySnapshot; +use crate::core::paths::PATHS; +use crate::environment::context_resolver::ResolvedContext; use crate::environment::types::{same_environment_identity, EnvironmentRef, EnvironmentStatus}; use crate::environment::wsl::operations::path_metadata::{ - self, PathMetadataContent, PathMetadataKind, PathMetadataQuery, + PathMetadataContent, PathMetadataFact, PathMetadataKind, PathMetadataQuery, }; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -#[cfg(all(test, unix))] -use crate::environment::wsl::operations::path_metadata::PATH_METADATA_SCRIPT as WSL_PATH_METADATA_SCRIPT; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnvironmentContext { pub environment: EnvironmentRef, @@ -35,6 +35,118 @@ pub struct EnvironmentContext { pub wsl_workspace: Option, } +pub(crate) fn native_environment_context(resolved: &ResolvedContext) -> EnvironmentContext { + let environment_variables = std::env::vars().collect::>(); + let home = resolved.home.native_path.clone(); + let config_home = PATHS.config_home.to_string_lossy().to_string(); + let revision = environment_revision( + "native", + &(home.clone(), config_home.clone(), &environment_variables), + ); + EnvironmentContext { + environment: EnvironmentRef::Native, + home, + config_home, + environment_variables, + availability: EnvironmentStatus::Available, + revision, + wsl_workspace: None, + } +} + +pub(crate) fn wsl_environment_context( + resolved: &ResolvedContext, + session: WslSession, + workspace: WslWorkspace, +) -> EnvironmentContext { + let revision = environment_revision("wsl", &session); + EnvironmentContext { + environment: resolved.context.environment.clone(), + home: session.home.clone(), + config_home: session.config_home.clone(), + environment_variables: session.environment.clone(), + availability: EnvironmentStatus::Available, + revision, + wsl_workspace: Some(workspace), + } +} + +fn environment_revision(value_kind: &str, value: &impl Serialize) -> String { + let mut hasher = Sha256::new(); + hasher.update(value_kind.as_bytes()); + hasher.update( + serde_json::to_vec(value) + .expect("environment revision inputs must serialize deterministically"), + ); + format!("{:x}", hasher.finalize()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EveProjectTargets { + pub has_eve: bool, + pub subagents: Vec, +} + +pub async fn inspect_eve_project( + workspace: &WslWorkspace, + project_path: &str, +) -> Result { + let project_path = project_path.trim_end_matches('/'); + let agent_path = format!("{project_path}/agent"); + let package_path = format!("{project_path}/package.json"); + let mut facts = workspace + .inspect_path_metadata(vec![ + PathMetadataQuery { + path: agent_path.clone(), + inspect_content: false, + }, + PathMetadataQuery { + path: package_path.clone(), + inspect_content: true, + }, + ]) + .await?; + let package = facts.pop().expect("two path queries return two facts"); + let agent = facts.pop().expect("two path queries return two facts"); + if !matches!( + agent.kind, + PathMetadataKind::Directory | PathMetadataKind::SymlinkDirectory + ) { + return Ok(EveProjectTargets { + has_eve: false, + subagents: Vec::new(), + }); + } + let bytes = match package.content { + PathMetadataContent::Bytes(bytes) => bytes, + PathMetadataContent::Empty => Vec::new(), + PathMetadataContent::NotRequested => { + return Ok(EveProjectTargets { + has_eve: false, + subagents: Vec::new(), + }); + } + PathMetadataContent::Unreadable => { + return Err(AppError::Path { + message: format!("Eve package is not readable: {package_path}"), + }); + } + }; + let package: serde_json::Value = serde_json::from_slice(&bytes)?; + let has_eve = ["dependencies", "devDependencies"] + .into_iter() + .any(|section| { + package + .get(section) + .and_then(serde_json::Value::as_object) + .is_some_and(|dependencies| dependencies.contains_key("eve")) + }); + let subagents = workspace + .list_child_directories(format!("{agent_path}/subagents"), 10_000) + .await?; + Ok(EveProjectTargets { has_eve, subagents }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -169,7 +281,6 @@ type MetadataQuery = enum MetadataBackend { Native, Wsl(WslWorkspace), - ActiveWsl(WslSession), Unavailable, #[cfg(test)] Custom(MetadataQuery), @@ -198,13 +309,13 @@ impl AgentEnvironmentResolver { } } - pub(crate) fn from_active_wsl_session( + pub(crate) fn from_active_wsl_workspace( context: EnvironmentContext, - session: WslSession, + workspace: WslWorkspace, ) -> Self { Self { environment_context: context, - metadata_backend: MetadataBackend::ActiveWsl(session), + metadata_backend: MetadataBackend::Wsl(workspace), cache: Mutex::new(BTreeMap::new()), } } @@ -470,9 +581,6 @@ impl AgentEnvironmentResolver { let metadata = match &self.metadata_backend { MetadataBackend::Native => query_native_metadata(queries), MetadataBackend::Wsl(workspace) => query_wsl_metadata(workspace, queries).await, - MetadataBackend::ActiveWsl(session) => { - query_active_wsl_metadata(session, queries).await - } #[cfg(test)] MetadataBackend::Custom(query) => query(queries), MetadataBackend::Unavailable => Ok(BTreeMap::new()), @@ -962,33 +1070,13 @@ async fn query_wsl_metadata( inspect_content: query.inspect_eve_package, }) .collect::>(), - None, ) .await?; metadata_from_typed_facts(facts) } -async fn query_active_wsl_metadata( - session: &WslSession, - queries: &[PathQuery], -) -> Result, AppError> { - let queries = queries - .iter() - .map(|query| PathMetadataQuery { - path: query.path.clone(), - inspect_content: query.inspect_eve_package, - }) - .collect::>(); - metadata_from_typed_facts(path_metadata::inspect(session, &queries, None).await?) -} - -#[cfg(test)] -fn parse_wsl_path_metadata(bytes: &[u8]) -> Result, AppError> { - metadata_from_typed_facts(path_metadata::parse_path_metadata(bytes)?) -} - fn metadata_from_typed_facts( - facts: Vec, + facts: Vec, ) -> Result, AppError> { let mut metadata = BTreeMap::new(); for fact in facts { @@ -1134,10 +1222,6 @@ fn join_posix(base: &str, child: &str) -> String { } #[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "Agent Environment 测试需要直接运行待验证的 shell 脚本" -)] mod tests { use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -1145,10 +1229,8 @@ mod tests { use tempfile::tempdir; - #[cfg(unix)] - use super::WSL_PATH_METADATA_SCRIPT; use super::{ - parse_wsl_path_metadata, AgentEnvironmentResolver, DirectoryPresenceState, + metadata_from_typed_facts, AgentEnvironmentResolver, DirectoryPresenceState, EnvironmentContext, PathEntryKind, PathMetadata, }; use crate::core::agent_definition::{ @@ -1159,6 +1241,9 @@ mod tests { use crate::core::agent_registry::AgentRegistrySnapshot; use crate::core::custom_agent_repository::CustomAgentRepository; use crate::environment::types::{EnvironmentRef, EnvironmentStatus}; + use crate::environment::wsl::operations::path_metadata::{ + PathMetadataContent, PathMetadataFact, PathMetadataKind, + }; fn scope( enabled: bool, @@ -2764,46 +2849,28 @@ mod tests { ); } - #[cfg(unix)] #[test] - fn wsl_metadata_script_keeps_empty_eve_payload_aligned_with_later_agent() { - let temp = tempfile::tempdir().unwrap(); - let package_path = temp.path().join("package.json"); - let other_agent_path = temp.path().join("other-agent"); - std::fs::write(&package_path, []).unwrap(); - std::fs::create_dir(&other_agent_path).unwrap(); - let package_path = package_path.to_string_lossy().into_owned(); - let other_agent_path = other_agent_path.to_string_lossy().into_owned(); - let output = std::process::Command::new("/bin/sh") - .args([ - "-c", - WSL_PATH_METADATA_SCRIPT, - "--", - "inspect", - package_path.as_str(), - "1", - other_agent_path.as_str(), - "0", - ]) - .output() - .unwrap(); - - let metadata = parse_wsl_path_metadata(&output.stdout).expect("parse metadata frame"); - - assert_eq!(metadata[&package_path].eve_package, Some(false)); - assert_eq!( - metadata[&other_agent_path].entry_kind, - PathEntryKind::Directory - ); - } - - #[test] - fn wsl_metadata_parser_keeps_unreadable_eve_and_inaccessible_path_records_distinct() { - let bytes = b"1\0path\0/work/package.json\0other\0eve-unreadable\0-\0path\0/home/alice/.other-agent\0directory\0none\0-\0path\0/home/alice/.blocked\0inaccessible\0none\0-\0"; - - let metadata = parse_wsl_path_metadata(bytes).expect("parse metadata frame"); + fn worker_metadata_facts_keep_content_and_path_states_distinct() { + let metadata = metadata_from_typed_facts(vec![ + PathMetadataFact { + path: "/work/package.json".to_string(), + kind: PathMetadataKind::Other, + content: PathMetadataContent::Empty, + }, + PathMetadataFact { + path: "/home/alice/.other-agent".to_string(), + kind: PathMetadataKind::Directory, + content: PathMetadataContent::NotRequested, + }, + PathMetadataFact { + path: "/home/alice/.blocked".to_string(), + kind: PathMetadataKind::Inaccessible, + content: PathMetadataContent::Unreadable, + }, + ]) + .expect("project Worker facts"); - assert_eq!(metadata["/work/package.json"].eve_package, None); + assert_eq!(metadata["/work/package.json"].eve_package, Some(false)); assert_eq!( metadata["/home/alice/.other-agent"].entry_kind, PathEntryKind::Directory @@ -2812,31 +2879,6 @@ mod tests { metadata["/home/alice/.blocked"].entry_kind, PathEntryKind::Inaccessible ); - } - - #[cfg(unix)] - #[test] - fn wsl_metadata_script_preserves_the_no_payload_record() { - let missing_path = tempfile::tempdir() - .unwrap() - .path() - .join("missing") - .to_string_lossy() - .into_owned(); - let output = std::process::Command::new("/bin/sh") - .args([ - "-c", - WSL_PATH_METADATA_SCRIPT, - "--", - "inspect", - missing_path.as_str(), - "0", - ]) - .output() - .unwrap(); - - let metadata = parse_wsl_path_metadata(&output.stdout).expect("parse metadata frame"); - - assert_eq!(metadata[&missing_path].entry_kind, PathEntryKind::Missing); + assert_eq!(metadata["/home/alice/.blocked"].eve_package, None); } } diff --git a/src-tauri/src/environment/context_resolver.rs b/src-tauri/src/environment/context_resolver.rs index bb2cb957..d7efd043 100644 --- a/src-tauri/src/environment/context_resolver.rs +++ b/src-tauri/src/environment/context_resolver.rs @@ -7,7 +7,7 @@ use crate::environment::types::{ SkillLocationRef, }; use crate::environment::wsl::operations::projects; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; pub struct ContextResolver; @@ -46,9 +46,10 @@ impl ContextResolver { pub async fn resolve_wsl( context: SkillLocationRef, session: &WslSession, + workspace: &WslWorkspace, ) -> Result { let projects = if matches!(context.scope, SkillLocation::Project { .. }) { - projects::read_projects(session).await? + projects::read_projects(session, workspace).await? } else { Vec::new() }; diff --git a/src-tauri/src/environment/directory_inspection.rs b/src-tauri/src/environment/directory_inspection.rs index 7ba92378..3a2b71fe 100644 --- a/src-tauri/src/environment/directory_inspection.rs +++ b/src-tauri/src/environment/directory_inspection.rs @@ -2,8 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::Path; -use crate::environment::wsl::operations::directory_count; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; pub const MAX_OBSERVED_SKILL_ENTRIES: u32 = 10_000; @@ -25,28 +24,27 @@ pub async fn inspect_native(paths: &[String]) -> BTreeMap Result, AppError> { let paths = unique_paths(paths); if paths.is_empty() { return Ok(BTreeMap::new()); } - Ok( - directory_count::inspect(session, &paths, MAX_OBSERVED_SKILL_ENTRIES) - .await? - .into_iter() - .map(|fact| { - ( - fact.path, - DirectoryInspection { - observed_skill_count: fact.observed_count, - observed_skill_count_truncated: fact.truncated, - }, - ) - }) - .collect(), - ) + Ok(workspace + .count_directory_entries(paths.clone(), MAX_OBSERVED_SKILL_ENTRIES) + .await? + .into_iter() + .map(|fact| { + ( + fact.path, + DirectoryInspection { + observed_skill_count: fact.observed_count, + observed_skill_count_truncated: fact.truncated, + }, + ) + }) + .collect()) } fn unique_paths(paths: &[String]) -> Vec { diff --git a/src-tauri/src/environment/native/inspection.rs b/src-tauri/src/environment/native/inspection.rs index f028bb11..7626a1c9 100644 --- a/src-tauri/src/environment/native/inspection.rs +++ b/src-tauri/src/environment/native/inspection.rs @@ -1,11 +1,17 @@ -use std::fs; -use std::io::Read; -use std::path::Path; +#[cfg(not(target_os = "linux"))] +use std::{fs, io::Read, path::Path}; + +#[cfg(target_os = "linux")] +use environment_engine::inspection::{ + self as engine_inspection, EntryKind as EngineEntryKind, ErrorCode as EngineErrorCode, + InspectionRequest, InspectionRoot, +}; use crate::environment::inspection::{ FilesystemEntryKind, FilesystemInspector, InspectionFuture, RawFilesystemSnapshot, RawPathFact, ReadPlan, ReadRootPurpose, }; +#[cfg(not(target_os = "linux"))] use crate::environment::native::tree::{inspect_entry_no_follow, NativeEntryKind}; use crate::environment::types::EnvironmentRef; use crate::error::AppError; @@ -48,6 +54,57 @@ impl FilesystemInspector for NativeInspector { } } +#[cfg(target_os = "linux")] +fn inspect_native(plan: &ReadPlan) -> Result { + let snapshot = engine_inspection::inspect(&InspectionRequest { + roots: plan + .roots + .iter() + .map(|root| InspectionRoot { + path: root.locator.native_path.clone().into(), + stat_only: root.purposes.len() == 1 + && root.purposes.contains(&ReadRootPurpose::Context), + }) + .collect(), + per_file_limit: plan.per_file_limit, + aggregate_limit: plan.aggregate_limit, + }) + .map_err(|error| AppError::ExecutionFailed { + message: format!("native inspection failed: {error}"), + })?; + + Ok(RawFilesystemSnapshot { + environment: EnvironmentRef::Native, + facts: snapshot + .facts + .into_iter() + .map(|fact| RawPathFact { + root_index: fact.root_index, + relative_path: fact.relative_path.to_string_lossy().into_owned(), + kind: match fact.kind { + EngineEntryKind::Missing => FilesystemEntryKind::Missing, + EngineEntryKind::File => FilesystemEntryKind::File, + EngineEntryKind::Directory => FilesystemEntryKind::Directory, + EngineEntryKind::Symlink => FilesystemEntryKind::Symlink, + EngineEntryKind::Other => FilesystemEntryKind::Other, + }, + resolved_target: fact + .resolved_target + .map(|target| target.to_string_lossy().into_owned()), + frontmatter_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + EngineErrorCode::PathUnavailable => "pathUnavailable".to_string(), + EngineErrorCode::ReadFailed => "readFailed".to_string(), + EngineErrorCode::ReadLinkFailed => "readLinkFailed".to_string(), + }), + }) + .collect(), + total_content_bytes: snapshot.total_content_bytes, + }) +} + +#[cfg(not(target_os = "linux"))] fn inspect_native(plan: &ReadPlan) -> Result { let mut facts = Vec::new(); let mut total = 0usize; @@ -119,6 +176,7 @@ fn inspect_native(plan: &ReadPlan) -> Result { }) } +#[cfg(not(target_os = "linux"))] fn inspect_path( path: &Path, root_index: u32, diff --git a/src-tauri/src/environment/path_mapping.rs b/src-tauri/src/environment/path_mapping.rs index b9d6eb76..f2484b4c 100644 --- a/src-tauri/src/environment/path_mapping.rs +++ b/src-tauri/src/environment/path_mapping.rs @@ -1,6 +1,5 @@ use crate::environment::types::EnvironmentRef; -use crate::environment::wsl::operations::path; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; pub(crate) fn parse_wsl_unc_path(path: &str) -> Option<(String, String)> { @@ -100,19 +99,10 @@ pub fn map_wsl_input_without_wslpath( } pub async fn map_windows_path_with_wslpath( - session: &WslSession, + workspace: &WslWorkspace, path: &str, ) -> Result { - match path::map_host_bridge_path(session, path, None).await { - Ok(mapped) => Ok(mapped), - Err(AppError::WslCommandFailed { .. }) => Err(AppError::StorageMappingUnsupported { - path: path.to_string(), - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - }), - Err(error) => Err(error), - } + workspace.map_host_path(path.to_string(), None).await } #[cfg(test)] diff --git a/src-tauri/src/environment/wsl/operations/acquire.rs b/src-tauri/src/environment/wsl/operations/acquire.rs index 3286604a..da57210e 100644 --- a/src-tauri/src/environment/wsl/operations/acquire.rs +++ b/src-tauri/src/environment/wsl/operations/acquire.rs @@ -1,7 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::Duration; use sha2::{Digest, Sha256}; -use tokio::time::Duration; use crate::application::payload_session::{ BackendAcquiredPayload, PayloadCleanupReport, PayloadCleanupWarning, PayloadCleanupWarningCode, @@ -10,73 +11,34 @@ use crate::application::payload_session::{ }; use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::{ - verify_skill_payload_integrity, verify_skill_payload_manifest, PayloadEntry, PayloadEntryKind, - SkillPayload, SkillPayloadManifest, + verify_skill_payload_integrity, PayloadEntry, PayloadEntryKind, SkillPayload, + SkillPayloadManifest, }; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, +use crate::environment::wsl::operations::source_acquisition::{ + WorkerSourceHandle, WslNativeSource, }; -use crate::environment::wsl::{WslSession, WslWorkspace}; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "1"; -#[cfg(all(test, target_os = "linux"))] -const OWNER_FILE: &str = ".skill-deck-owner"; -#[cfg(all(test, target_os = "linux"))] -const MANIFEST_FILE: &str = "manifest.json"; -#[cfg(all(test, target_os = "linux"))] -const BLOB_LIST_FILE: &str = "blob-list"; const MAX_MANIFEST_BYTES: usize = 8 * 1024 * 1024; -const MAX_BRIDGE_BLOB_BYTES: usize = 256 * 1024 * 1024; +const MAX_BLOB_BYTES: usize = 256 * 1024 * 1024; -const ACQUIRE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_BEGIN_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_BLOB_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const STORE_FINALIZE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const FINALIZE_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const VERIFY_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const READ_BLOB_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const REMOVE_PAYLOAD_SCRIPT: &str = include_str!("../scripts/acquire.sh"); - -const REMOVE_SESSION_SCRIPT: &str = include_str!("../scripts/acquire.sh"); +#[derive(Debug, Clone)] +struct SourceBinding { + handle: WorkerSourceHandle, + root: String, +} -const SWEEP_ORPHANS_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const SOURCE_FINGERPRINT_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const SOURCE_REVISION_SCRIPT: &str = include_str!("../scripts/acquire.sh"); -const ACQUIRE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "acquire", ACQUIRE_SCRIPT); -const STORE_BEGIN_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-begin", STORE_BEGIN_SCRIPT); -const STORE_BLOB_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-blob", STORE_BLOB_SCRIPT); -const STORE_FINALIZE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "store-finalize", STORE_FINALIZE_SCRIPT); -const FINALIZE_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "finalize", FINALIZE_SCRIPT); -const VERIFY_OPERATION: WslOperationDescriptor = wsl_operation("payload", "verify", VERIFY_SCRIPT); -const READ_BLOB_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "read-blob", READ_BLOB_SCRIPT); -const REMOVE_PAYLOAD_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "remove-payload", REMOVE_PAYLOAD_SCRIPT); -const REMOVE_SESSION_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "remove-session", REMOVE_SESSION_SCRIPT); -const SWEEP_ORPHANS_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "sweep-orphans", SWEEP_ORPHANS_SCRIPT); -const SOURCE_FINGERPRINT_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "fingerprint", SOURCE_FINGERPRINT_SCRIPT); -const SOURCE_REVISION_OPERATION: WslOperationDescriptor = - wsl_operation("payload", "source-revision", SOURCE_REVISION_SCRIPT); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WorkerPayloadHandle { + generation: u64, + id: u64, +} pub struct WslPayloadSessionStorage { workspace: WslWorkspace, + source: Option, + handles: Mutex>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -88,64 +50,194 @@ pub struct WslAcquiredPayload { impl WslPayloadSessionStorage { pub fn new(workspace: WslWorkspace) -> Self { - Self { workspace } + Self { + workspace, + source: None, + handles: Mutex::new(HashMap::new()), + } } - fn managed_paths(&self, key: &PayloadStorageKey) -> Result<(String, String), AppError> { - let session_root = managed_session_root(key.session_id())?; - let payload_root = format!("{session_root}/payload-{}", digest(key.skill_path())); - Ok((session_root, payload_root)) + pub(crate) fn for_source(workspace: WslWorkspace, source: &WslNativeSource) -> Self { + Self { + workspace, + source: Some(SourceBinding { + handle: source.handle(), + root: source.native_root().to_string(), + }), + handles: Mutex::new(HashMap::new()), + } } - async fn run( - &self, - operation: &WslOperationDescriptor, - args: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - self.run_with( - operation, - args, - Vec::new(), - Duration::from_secs(30), - stdout_limit, - None, - ) - .await + fn payload_name(&self, key: &PayloadStorageKey) -> String { + format!("payload-{}", digest(key.skill_path())) + } + + fn remember_handle(&self, key: &PayloadStorageKey, generation: u64, id: u64) { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .insert(key.clone(), WorkerPayloadHandle { generation, id }); + } + + fn forget_key(&self, key: &PayloadStorageKey) { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .remove(key); + } + + fn handle(&self, key: &PayloadStorageKey) -> Result { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .get(key) + .copied() + .ok_or(AppError::StalePayload) + } + + async fn remove_key_best_effort(&self, key: &PayloadStorageKey) { + let _ = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayload { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + }, + None, + Duration::from_secs(10), + ) + .await; + self.forget_key(key); } - async fn run_with( + async fn store_in_worker( &self, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - timeout: Duration, - stdout_limit: usize, - cancellation: Option, - ) -> Result, AppError> { - self.workspace - .with_session_retry(move |session| { - let args = args.clone(); - let stdin = stdin.clone(); - let cancellation = cancellation.clone(); - async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args, - stdin, - timeout, - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, + key: &PayloadStorageKey, + payload: SkillPayload, + ) -> Result { + verify_skill_payload_integrity(&payload)?; + let manifest = payload.manifest(); + let payload_name = self.payload_name(key); + let (generation, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::BeginPayloadUpload { + session_id: key.session_id().to_string(), + payload_name: payload_name.clone(), + }, + None, + Duration::from_secs(10), + ) + .await?; + let upload_id = match response { + environment_protocol::Message::PayloadUploadBegun { upload_id } => upload_id, + message => return Err(worker_response_error(message, "PayloadUploadBegun")), + }; + let result = async { + for (blob_id, blob) in &payload.blobs { + if blob.len() > MAX_BLOB_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslPayloadBlobSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{blob_id}"); + let response = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::UploadPayloadBlob { + upload_id, + blob_id: blob_id.clone(), + total_bytes: blob.len() as u64, + sha256: digest, }, + None, + Duration::from_secs(10), ) - .await - .map(|output| output.stdout) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(worker_response_error(message, "TransferReady")), + }; + let response = self + .workspace + .send_worker_transfer_for_generation( + generation, + transfer_id, + blob, + MAX_BLOB_BYTES, + Duration::from_secs(60), + ) + .await?; + match response { + environment_protocol::Message::PayloadBlobUploaded { + upload_id: actual_upload, + blob_id: actual_blob, + } if actual_upload == upload_id && actual_blob == *blob_id => {} + message => { + return Err(worker_response_error(message, "PayloadBlobUploaded")); + } } + } + let manifest_bytes = serde_json::to_vec(&manifest)?; + if manifest_bytes.is_empty() || manifest_bytes.len() > MAX_MANIFEST_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslPayloadManifestSize".to_string(), + path: None, + }); + } + let manifest_sha = format!("sha256:{:x}", Sha256::digest(&manifest_bytes)); + let response = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::FinalizePayloadUpload { + upload_id, + total_bytes: manifest_bytes.len() as u64, + sha256: manifest_sha, + }, + None, + Duration::from_secs(10), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(worker_response_error(message, "TransferReady")), + }; + let response = self + .workspace + .send_worker_transfer_for_generation( + generation, + transfer_id, + &manifest_bytes, + MAX_MANIFEST_BYTES, + Duration::from_secs(30), + ) + .await?; + let payload_id = match response { + environment_protocol::Message::PayloadUploadFinalized { payload_id, .. } => { + payload_id + } + message => return Err(worker_response_error(message, "PayloadUploadFinalized")), + }; + self.remember_handle(key, generation, payload_id); + Ok(payload.blobs.values().map(|blob| blob.len() as u64).sum()) + } + .await; + if result.is_err() { + self.remove_key_best_effort(key).await; + } + result + } + + fn source_binding(&self) -> Result<&SourceBinding, AppError> { + self.source + .as_ref() + .ok_or_else(|| AppError::CapabilityUnavailable { + capability: "wslSourceHandle".to_string(), + path: None, }) - .await } pub async fn acquire_from_path( @@ -154,105 +246,103 @@ impl WslPayloadSessionStorage { source_root: &str, cancellation: Option, ) -> Result { + if let Some(source) = &self.source { + return self + .acquire_from_bound_source(key, source, source_root, cancellation) + .await; + } if !source_root.starts_with('/') { return Err(AppError::UnsafePath { path: source_root.to_string(), reason: "WSL payload source must be an absolute POSIX path".to_string(), }); } - let (session_root, payload_root) = self.managed_paths(key)?; - let base_args = vec![ - source_root.to_string(), - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let response = self - .run_with( - &ACQUIRE_OPERATION, - base_args, - Vec::new(), - Duration::from_secs(60), - MAX_MANIFEST_BYTES, + let (generation, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::OpenLocalSource { + request: environment_protocol::OpenLocalSourceRequest { + path: source_root.to_string(), + }, + }, cancellation.clone(), + Duration::from_secs(10), ) .await?; - let acquired = match parse_acquire_response(&response) { - Ok(acquired) => acquired, - Err(error) => { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await; - return Err(error); - } + let (source_id, root) = match response { + environment_protocol::Message::SourceOpened { + source_id, root, .. + } => (source_id, root), + message => return Err(worker_response_error(message, "SourceOpened")), }; - let finalize_args = vec![ - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let finalize = self - .run_with( - &FINALIZE_OPERATION, - finalize_args, - finalize_request(&acquired.manifest)?, - Duration::from_secs(30), - 32, - cancellation, + let source = SourceBinding { + handle: WorkerSourceHandle { + generation, + id: source_id, + }, + root, + }; + let result = self + .acquire_from_bound_source(key, &source, source_root, cancellation) + .await; + let _ = self + .workspace + .request_worker_control_for_generation( + generation, + environment_protocol::Message::ReleaseSource { source_id }, + None, + Duration::from_secs(10), ) - .await - .and_then(|response| parse_finalize_response(&response)); - if let Err(error) = finalize { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await; - return Err(error); - } - Ok(acquired) + .await; + result } - pub(crate) async fn source_metadata_fingerprint_in_active_session( + async fn acquire_from_bound_source( &self, - session: &WslSession, + key: &PayloadStorageKey, + source: &SourceBinding, source_root: &str, - ) -> Result { - if !source_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: source_root.to_string(), - reason: "WSL payload source must be an absolute POSIX path".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &SOURCE_FINGERPRINT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![source_root.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(30), - stdout_limit: 128, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - parse_source_fingerprint(&output.stdout) + cancellation: Option, + ) -> Result { + let relative_path = relative_source_path(&source.root, source_root)?; + let response: environment_protocol::PayloadReadyResponse = self + .workspace + .request_worker_payload_for_generation( + source.handle.generation, + environment_protocol::Message::AcquirePayloadFromSource { + request: environment_protocol::AcquirePayloadFromSourceRequest { + source_id: source.handle.id, + relative_path: relative_path.into_bytes(), + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + deadline_millis: 60_000, + }, + }, + MAX_MANIFEST_BYTES, + cancellation, + Duration::from_secs(65), + ) + .await?; + self.remember_handle(key, source.handle.generation, response.payload_id); + Ok(WslAcquiredPayload { + manifest: map_manifest(response.manifest)?, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash.ok_or_else(|| { + AppError::ConfigurationCorrupted { + message: "WSL Worker source payload omitted its CLI hash".to_string(), + } + })?, + }) } } impl PayloadSessionStorage for WslPayloadSessionStorage { fn local_source(&self, key: &PayloadStorageKey) -> Result { - let (_, payload_root) = self.managed_paths(key)?; + let handle = self.handle(key)?; Ok(PayloadLocalSource::WslManaged { distro_name: self.workspace.distro_name().to_string(), - payload_root, + worker_generation: handle.generation, + worker_payload_id: handle.id, }) } @@ -261,82 +351,43 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, payload: SkillPayload, ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async move { self.store_in_worker(key, payload).await }) + } + + fn acquire_from_source_path<'a>( + &'a self, + key: &'a PayloadStorageKey, + source_root: &'a str, + cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { Box::pin(async move { - verify_skill_payload_integrity(&payload)?; - let manifest = payload.manifest(); - let total_bytes = payload.blobs.values().map(|blob| blob.len() as u64).sum(); - let (session_root, payload_root) = self.managed_paths(key)?; - let session_id = key.session_id().to_string(); - let stage_root = format!("{payload_root}.upload"); - let store_result = async { - let response = self - .run_with( - &STORE_BEGIN_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - ], - Vec::new(), - Duration::from_secs(10), - 32, - None, - ) - .await?; - parse_finalize_response(&response)?; - for (blob_id, blob) in &payload.blobs { - let response = self - .run_with( - &STORE_BLOB_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - blob_id.clone(), - ], - blob.clone(), - Duration::from_secs(60), - 32, - None, - ) - .await?; - parse_finalize_response(&response)?; - } - let request = finalize_request(&manifest)?; - if request.len() > MAX_MANIFEST_BYTES { - return Err(AppError::CapabilityUnavailable { - capability: "wslPayloadManifestSize".to_string(), - path: None, - }); - } - let response = self - .run_with( - &STORE_FINALIZE_OPERATION, - vec![ - session_root.clone(), - payload_root.clone(), - session_id.clone(), - ], - request, - Duration::from_secs(30), - 32, - None, - ) + let source = self.source_binding()?; + let response = self + .acquire_from_bound_source(key, source, source_root, cancellation) + .await?; + Ok(BackendAcquiredPayload { + manifest: response.manifest, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash, + }) + }) + } + + fn acquire_from_path<'a>( + &'a self, + key: &'a PayloadStorageKey, + source_root: &'a str, + cancellation: Option, + ) -> PayloadStorageFuture<'a, Result> { + Box::pin(async move { + let response = + WslPayloadSessionStorage::acquire_from_path(self, key, source_root, cancellation) .await?; - parse_finalize_response(&response)?; - Ok(total_bytes) - } - .await; - if store_result.is_err() { - let _ = self - .run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, stage_root, session_id], - 0, - ) - .await; - } - store_result + Ok(BackendAcquiredPayload { + manifest: response.manifest, + total_bytes: response.total_bytes, + computed_hash: response.computed_hash, + }) }) } @@ -345,20 +396,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { source_root: &'a str, ) -> PayloadStorageFuture<'a, Result> { Box::pin(async move { - if !source_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: source_root.to_string(), - reason: "WSL payload source must be an absolute POSIX path".to_string(), - }); - } + let source = self.source_binding()?; + let relative_path = relative_source_path(&source.root, source_root)?; let response = self - .run( - &SOURCE_FINGERPRINT_OPERATION, - vec![source_root.to_string()], - 128, + .workspace + .request_worker_control_for_generation( + source.handle.generation, + environment_protocol::Message::SourceFingerprint { + source_id: source.handle.id, + relative_path: relative_path.into_bytes(), + deadline_millis: 30_000, + }, + None, + Duration::from_secs(35), ) .await?; - parse_source_fingerprint(&response) + match response { + environment_protocol::Message::SourceFingerprintResult { fingerprint } => { + Ok(fingerprint) + } + message => Err(worker_response_error(message, "SourceFingerprintResult")), + } }) } @@ -368,39 +426,29 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { skill_path: &'a str, ) -> PayloadStorageFuture<'a, Result, AppError>> { Box::pin(async move { - if !repository_root.starts_with('/') { - return Err(AppError::UnsafePath { - path: repository_root.to_string(), - reason: "WSL Git source root must be an absolute POSIX path".to_string(), - }); + let source = self.source_binding()?; + if repository_root != source.root { + return Err(AppError::StalePayload); } - let skill_directory = crate::core::skill_paths::normalize_skill_folder_path(skill_path); let response = self - .run( - &SOURCE_REVISION_OPERATION, - vec![repository_root.to_string(), skill_directory], - 128, + .workspace + .request_worker_control_for_generation( + source.handle.generation, + environment_protocol::Message::SourceRevision { + source_id: source.handle.id, + relative_path: normalize_skill_revision_path(skill_path)?.into_bytes(), + deadline_millis: 30_000, + }, + None, + Duration::from_secs(35), ) .await?; - parse_source_revision(&response).map(Some) - }) - } - - fn acquire_from_source_path<'a>( - &'a self, - key: &'a PayloadStorageKey, - source_root: &'a str, - cancellation: Option, - ) -> PayloadStorageFuture<'a, Result> { - Box::pin(async move { - let acquired = self - .acquire_from_path(key, source_root, cancellation) - .await?; - Ok(BackendAcquiredPayload { - manifest: acquired.manifest, - total_bytes: acquired.total_bytes, - computed_hash: acquired.computed_hash, - }) + match response { + environment_protocol::Message::SourceRevisionResult { revision } => { + Ok(Some(revision)) + } + message => Err(worker_response_error(message, "SourceRevisionResult")), + } }) } @@ -409,40 +457,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, ) -> PayloadStorageFuture<'a, Result, AppError>> { Box::pin(async move { - let (session_root, payload_root) = self.managed_paths(key)?; - let base_args = vec![ - session_root.clone(), - payload_root.clone(), - key.session_id().to_string(), - ]; - let response = self - .run(&VERIFY_OPERATION, base_args.clone(), MAX_MANIFEST_BYTES) - .await; - let manifest = match response { - Ok(response) => parse_manifest_response(&response)?, - Err(AppError::WslCommandFailed { - exit_code: Some(69..=72), - .. - }) => return Ok(None), - Err(error) => return Err(error), + let (generation, response): (u64, Option) = + self.workspace + .request_worker_payload_once( + environment_protocol::Message::VerifyPayload { + request: environment_protocol::VerifyPayloadRequest { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + deadline_millis: 30_000, + }, + }, + MAX_MANIFEST_BYTES, + None, + Duration::from_secs(35), + ) + .await?; + let Some(response) = response else { + self.forget_key(key); + return Ok(None); }; - let mut exact_args = base_args; - exact_args.push("--expected".to_string()); - let exact_response = self - .run_with( - &VERIFY_OPERATION, - exact_args, - expected_blob_list(&manifest), - Duration::from_secs(30), - MAX_MANIFEST_BYTES, - None, - ) - .await?; - let exact_manifest = parse_manifest_response(&exact_response)?; - if exact_manifest != manifest { - return Err(AppError::StalePayload); - } - Ok(Some(manifest)) + self.remember_handle(key, generation, response.payload_id); + map_manifest(response.manifest).map(Some) }) } @@ -455,25 +490,27 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { if !valid_blob_id(blob_id) { return Err(AppError::StalePayload); } - let (session_root, payload_root) = self.managed_paths(key)?; - let response = self - .run( - &READ_BLOB_OPERATION, - vec![ - session_root, - payload_root, - key.session_id().to_string(), - blob_id.to_string(), - ], - MAX_BRIDGE_BLOB_BYTES, + let handle = self.handle(key)?; + let result = self + .workspace + .request_worker_bytes_for_generation( + handle.generation, + environment_protocol::Message::ReadPayloadBlob { + payload_id: handle.id, + blob_id: blob_id.to_string(), + deadline_millis: 60_000, + }, + MAX_BLOB_BYTES, + Duration::from_secs(65), ) .await; - match response { - Ok(response) => Ok(Some(parse_blob_response(&response)?)), - Err(AppError::WslCommandFailed { - exit_code: Some(63 | 71 | 72), - .. - }) => Ok(None), + match result { + Ok(blob) => Ok(Some(blob)), + Err(AppError::ExecutionFailed { message }) + if message.contains("missingPayload") => + { + Ok(None) + } Err(error) => Err(error), } }) @@ -484,14 +521,24 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { key: &'a PayloadStorageKey, ) -> PayloadStorageFuture<'a, Result<(), AppError>> { Box::pin(async move { - let (session_root, payload_root) = self.managed_paths(key)?; - self.run( - &REMOVE_PAYLOAD_OPERATION, - vec![session_root, payload_root, key.session_id().to_string()], - 0, - ) - .await?; - Ok(()) + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayload { + session_id: key.session_id().to_string(), + payload_name: self.payload_name(key), + }, + None, + Duration::from_secs(10), + ) + .await?; + match response { + environment_protocol::Message::PayloadRemoved { .. } => { + self.forget_key(key); + Ok(()) + } + message => Err(worker_response_error(message, "PayloadRemoved")), + } }) } @@ -500,14 +547,26 @@ impl PayloadSessionStorage for WslPayloadSessionStorage { session_id: &'a str, ) -> PayloadStorageFuture<'a, Result<(), AppError>> { Box::pin(async move { - let session_root = managed_session_root(session_id)?; - self.run( - &REMOVE_SESSION_OPERATION, - vec![session_root, session_id.to_string()], - 0, - ) - .await?; - Ok(()) + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::RemovePayloadSession { + session_id: session_id.to_string(), + }, + None, + Duration::from_secs(10), + ) + .await?; + match response { + environment_protocol::Message::PayloadSessionRemoved { .. } => { + self.handles + .lock() + .expect("WSL payload handle map lock poisoned") + .retain(|key, _| key.session_id() != session_id); + Ok(()) + } + message => Err(worker_response_error(message, "PayloadSessionRemoved")), + } }) } } @@ -520,896 +579,160 @@ impl PayloadSessionMaintenance for WslPayloadSessionStorage { Box::pin(async move { let mut protected = protected_session_ids.iter().cloned().collect::>(); protected.sort(); - let mut args = Vec::with_capacity(protected.len() + 1); - args.push("/tmp".to_string()); - args.extend(protected); - let response = self - .run(&SWEEP_ORPHANS_OPERATION, args, 1024 * 1024) + let (_, response): (u64, environment_protocol::PayloadCleanupResponse) = self + .workspace + .request_worker_payload_once( + environment_protocol::Message::SweepPayloadOrphans { + protected_session_ids: protected, + }, + 1024 * 1024, + None, + Duration::from_secs(35), + ) .await?; - parse_cleanup_report(&response) + Ok(PayloadCleanupReport { + removed_sessions: response.removed_sessions as usize, + protected_sessions: response.protected_sessions as usize, + external_retained_bytes: response.retained_external_bytes, + capacity_blocked: response.cleanup_blocked, + warnings: response + .warnings + .into_iter() + .map(|warning| { + Ok::<_, AppError>(PayloadCleanupWarning { + code: cleanup_warning_code(&warning.code)?, + candidate_name: warning.candidate_name, + technical_details: warning.technical_details, + }) + }) + .collect::>()?, + }) }) } } -fn managed_session_root(session_id: &str) -> Result { - if session_id.is_empty() - || session_id.len() > 128 - || !session_id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +fn map_manifest( + manifest: environment_protocol::PayloadManifest, +) -> Result { + let expected_root_hash = manifest.payload_root_hash.clone(); + let expected_payload_id = manifest.payload_id.clone(); + let entries = manifest + .entries + .into_iter() + .map(|entry| PayloadEntry { + relative_path: entry.relative_path, + kind: match entry.kind { + environment_protocol::PayloadEntryKind::File => PayloadEntryKind::File, + environment_protocol::PayloadEntryKind::Directory => PayloadEntryKind::Directory, + }, + blob_id: entry.blob_id, + content_hash: entry.content_hash, + size: entry.size, + executable: entry.executable, + }) + .collect(); + let mapped = SkillPayloadManifest::from_entries(entries)?; + if mapped.payload_id().as_str() != expected_payload_id + || mapped.payload_root_hash != expected_root_hash { return Err(AppError::StalePayload); } - Ok(format!("/tmp/skill-deck-source-{session_id}")) + Ok(mapped) } -fn digest(value: &str) -> String { - format!("{:x}", Sha256::digest(value.as_bytes())) -} - -fn valid_blob_id(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) -} - -fn parse_manifest_response(bytes: &[u8]) -> Result { - let manifest = serde_json::from_slice(bytes)?; - verify_skill_payload_manifest(&manifest)?; - Ok(manifest) -} - -fn parse_acquire_response(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error("unsupported WSL acquire protocol version")); - } - if take_text_field(bytes, &mut cursor)? != "H" { - return Err(protocol_error("missing WSL acquire CLI hash record")); +fn relative_source_path(source_root: &str, requested: &str) -> Result { + if requested == source_root { + return Ok(String::new()); } - let computed_hash = take_text_field(bytes, &mut cursor)?.to_string(); - if !valid_blob_id(&computed_hash) { - return Err(protocol_error("invalid WSL acquire CLI hash")); - } - let mut entries = Vec::new(); - let mut blob_sizes = BTreeMap::new(); - while cursor < bytes.len() { - if take_text_field(bytes, &mut cursor)? != "E" { - return Err(protocol_error("invalid WSL acquire record tag")); - } - let kind = take_text_field(bytes, &mut cursor)?; - let relative_path = take_text_field(bytes, &mut cursor)?.to_string(); - let blob_id = take_text_field(bytes, &mut cursor)?; - let size = parse_text_field::(bytes, &mut cursor, "entry size")?; - let executable = match take_text_field(bytes, &mut cursor)? { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid WSL executable flag")), - }; - let entry = match kind { - "directory" if blob_id.is_empty() && size == 0 && !executable => PayloadEntry { - relative_path, - kind: PayloadEntryKind::Directory, - blob_id: None, - content_hash: None, - size: 0, - executable: false, - }, - "file" if valid_blob_id(blob_id) => { - match blob_sizes.insert(blob_id.to_string(), size) { - Some(previous) if previous != size => { - return Err(protocol_error("conflicting WSL blob sizes")); - } - _ => {} - } - PayloadEntry { - relative_path, - kind: PayloadEntryKind::File, - blob_id: Some(blob_id.to_string()), - content_hash: Some(blob_id.to_string()), - size, - executable, - } - } - _ => return Err(protocol_error("invalid WSL acquire entry")), - }; - entries.push(entry); - } - let manifest = SkillPayloadManifest::from_entries(entries)?; - Ok(WslAcquiredPayload { - manifest, - total_bytes: blob_sizes.values().copied().sum(), - computed_hash, - }) + requested + .strip_prefix(source_root) + .and_then(|relative| relative.strip_prefix('/')) + .map(normalize_relative_path) + .transpose()? + .ok_or(AppError::StalePayload) } -fn parse_source_fingerprint(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error( - "unsupported WSL source fingerprint protocol version", - )); - } - let fingerprint = take_text_field(bytes, &mut cursor)?.to_string(); - if cursor != bytes.len() || !valid_blob_id(&fingerprint) { - return Err(protocol_error("invalid WSL source metadata fingerprint")); - } - Ok(fingerprint) +fn normalize_skill_revision_path(path: &str) -> Result { + let path = crate::core::skill_paths::normalize_skill_folder_path(path); + normalize_relative_path(&path) } -fn parse_source_revision(bytes: &[u8]) -> Result { - let mut cursor = 0; - if take_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error( - "unsupported WSL source revision protocol version", - )); +fn normalize_relative_path(path: &str) -> Result { + if path.is_empty() { + return Ok(String::new()); } - let revision = take_text_field(bytes, &mut cursor)?.to_string(); - if cursor != bytes.len() - || !matches!(revision.len(), 40 | 64) - || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + if path.starts_with('/') + || path.contains('\\') + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) { - return Err(protocol_error("invalid WSL source revision")); - } - Ok(revision.to_ascii_lowercase()) -} - -fn parse_finalize_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0") - .then_some(()) - .ok_or_else(|| protocol_error("invalid WSL acquire finalize response")) -} - -fn expected_blob_ids(manifest: &SkillPayloadManifest) -> BTreeSet<&str> { - manifest - .entries - .iter() - .filter_map(|entry| entry.blob_id.as_deref()) - .collect() -} - -fn expected_blob_list(manifest: &SkillPayloadManifest) -> Vec { - let mut list = expected_blob_ids(manifest) - .into_iter() - .collect::>() - .join("\n") - .into_bytes(); - if !list.is_empty() { - list.push(b'\n'); + return Err(AppError::StalePayload); } - list + Ok(path.to_string()) } -fn finalize_request(manifest: &SkillPayloadManifest) -> Result, AppError> { - let ids = expected_blob_ids(manifest); - let mut request = format!("{}\n", ids.len()).into_bytes(); - for id in ids { - request.extend_from_slice(id.as_bytes()); - request.push(b'\n'); +fn worker_response_error(message: environment_protocol::Message, expected: &str) -> AppError { + match message { + environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { + message: format!("WSL Worker request failed during {phase}: {code}"), + }, + _ => AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected} response"), + }, } - request.extend(serde_json::to_vec(manifest)?); - Ok(request) } -fn take_text_field<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a str, AppError> { - let remaining = bytes - .get(*cursor..) - .ok_or_else(|| protocol_error("WSL acquire cursor is out of range"))?; - let length = remaining - .iter() - .position(|byte| *byte == 0) - .ok_or_else(|| protocol_error("WSL acquire field terminator is missing"))?; - let field = std::str::from_utf8(&remaining[..length]) - .map_err(|_| protocol_error("WSL acquire field is not UTF-8"))?; - *cursor += length + 1; - Ok(field) -} - -fn parse_text_field(bytes: &[u8], cursor: &mut usize, field: &str) -> Result -where - T: std::str::FromStr, -{ - take_text_field(bytes, cursor)? - .parse() - .map_err(|_| protocol_error(&format!("invalid WSL acquire {field}"))) -} - -fn protocol_error(message: &str) -> AppError { - AppError::ConfigurationCorrupted { - message: message.to_string(), +fn cleanup_warning_code(code: &str) -> Result { + match code { + "unknownEntry" => Ok(PayloadCleanupWarningCode::UnknownEntry), + "invalidMarker" => Ok(PayloadCleanupWarningCode::InvalidMarker), + "futureMarkerVersion" => Ok(PayloadCleanupWarningCode::FutureMarkerVersion), + "boundaryRejected" => Ok(PayloadCleanupWarningCode::BoundaryRejected), + "deleteFailed" => Ok(PayloadCleanupWarningCode::DeleteFailed), + "sizeUnavailable" => Ok(PayloadCleanupWarningCode::SizeUnavailable), + _ => Err(AppError::ConfigurationCorrupted { + message: format!("unknown WSL payload cleanup warning code: {code}"), + }), } } -fn parse_blob_response(bytes: &[u8]) -> Result, AppError> { - Ok(bytes.to_vec()) +fn digest(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) } -fn parse_cleanup_report(bytes: &[u8]) -> Result { - let records = bytes - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - .map(|record| String::from_utf8_lossy(record).into_owned()) - .collect::>(); - if records.first().map(String::as_str) != Some(PROTOCOL_VERSION) { - return Err(protocol_error("invalid payload cleanup protocol version")); - } - let mut cursor = 1; - let mut report = PayloadCleanupReport::default(); - while cursor < records.len() { - match records[cursor].as_str() { - "W" if cursor + 3 < records.len() => { - let code = match records[cursor + 1].as_str() { - "unknownEntry" => PayloadCleanupWarningCode::UnknownEntry, - "invalidMarker" => PayloadCleanupWarningCode::InvalidMarker, - "futureMarkerVersion" => PayloadCleanupWarningCode::FutureMarkerVersion, - "boundaryRejected" => PayloadCleanupWarningCode::BoundaryRejected, - "deleteFailed" => PayloadCleanupWarningCode::DeleteFailed, - "sizeUnavailable" => PayloadCleanupWarningCode::SizeUnavailable, - _ => return Err(protocol_error("unknown payload cleanup warning code")), - }; - report.warnings.push(PayloadCleanupWarning { - code, - candidate_name: Some(records[cursor + 2].clone()), - technical_details: (records[cursor + 3] != "-") - .then(|| records[cursor + 3].clone()), - }); - cursor += 4; - } - "S" if cursor + 4 < records.len() => { - report.removed_sessions = records[cursor + 1] - .parse() - .map_err(|_| protocol_error("invalid removed session count"))?; - report.protected_sessions = records[cursor + 2] - .parse() - .map_err(|_| protocol_error("invalid protected session count"))?; - report.external_retained_bytes = records[cursor + 3] - .parse() - .map_err(|_| protocol_error("invalid external retained bytes"))?; - report.capacity_blocked = match records[cursor + 4].as_str() { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid payload capacity state")), - }; - cursor += 5; - } - _ => return Err(protocol_error("malformed payload cleanup response")), - } - } - Ok(report) +fn valid_blob_id(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "获取协议测试需要直接调用真实 Git 并运行 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::collections::BTreeMap; - use std::collections::BTreeSet; - use std::fs; - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - - use super::*; - use crate::application::payload_session::PayloadLocalSource; - use crate::core::skill_payload::{ - build_skill_payload, compute_cli_project_hash_from_payload, SkillPayloadManifest, - }; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - fn storage() -> WslPayloadSessionStorage { - let runtime = crate::environment::wsl::WslRuntime::default(); - runtime.insert(session()); - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")) - } - #[test] - fn local_source_is_an_opaque_backend_owned_wsl_path() { - let storage = storage(); - let key = PayloadStorageKey::new("session-1", "skills/demo"); + fn source_paths_are_relative_to_the_bound_worker_source() { assert_eq!( - storage.local_source(&key).expect("local source"), - PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: format!( - "/tmp/skill-deck-source-session-1/payload-{}", - digest("skills/demo") - ), - } + super::relative_source_path("/home/alice/repo", "/home/alice/repo/skills/demo") + .unwrap(), + "skills/demo" ); + assert!(super::relative_source_path("/home/alice/repo", "/home/alice/other").is_err()); } #[test] - fn source_revision_script_returns_selected_git_tree_object_id() { - let repo = tempdir().expect("repo"); - let skill = repo.path().join("skills/demo"); - fs::create_dir_all(&skill).expect("skill"); - fs::write(skill.join("SKILL.md"), b"demo").expect("document"); - for args in [ - vec!["init"], - vec!["config", "user.email", "test@example.com"], - vec!["config", "user.name", "Skill Deck Test"], - vec!["add", "."], - vec!["commit", "-m", "fixture"], - ] { - let output = Command::new("git") - .arg("-C") - .arg(repo.path()) - .args(args) - .output() - .expect("git command"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - let expected = Command::new("git") - .arg("-C") - .arg(repo.path()) - .args(["rev-parse", "HEAD:skills/demo"]) - .output() - .expect("expected revision"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("source-revision") - .arg(repo.path()) - .arg("skills/demo") - .output() - .expect("source revision script"); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); + fn source_revision_uses_the_skill_directory_instead_of_the_skill_md_blob() { assert_eq!( - output.stdout, - format!("1\0{}\0", String::from_utf8_lossy(&expected.stdout).trim()).into_bytes() + super::normalize_skill_revision_path("quick-brainstorm/SKILL.md").unwrap(), + "quick-brainstorm" ); - } - - #[test] - fn source_revision_parser_rejects_non_git_hashes() { - assert_eq!( - parse_source_revision(format!("1\0{}\0", "A".repeat(40)).as_bytes()).unwrap(), - "a".repeat(40) - ); - assert!(parse_source_revision(format!("1\0{}\0", "a".repeat(39)).as_bytes()).is_err()); - assert!(parse_source_revision(format!("1\0{}z\0", "a".repeat(39)).as_bytes()).is_err()); - } - - fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, SkillPayloadManifest) { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).unwrap(); - fs::write(source.join("SKILL.md"), [0, 1, 2, 255]).unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let manifest = payload.manifest(); - let session_root = temp.path().join("skill-deck-source-session-1"); - let payload_root = session_root.join("payload-demo"); - fs::create_dir_all(payload_root.join("blobs")).unwrap(); - fs::write(session_root.join(OWNER_FILE), b"1\nsession-1\n").unwrap(); - fs::write( - payload_root.join(MANIFEST_FILE), - serde_json::to_vec(&manifest).unwrap(), - ) - .unwrap(); - let ids = payload.blobs.keys().cloned().collect::>(); - fs::write( - payload_root.join(BLOB_LIST_FILE), - ids.iter() - .map(String::as_str) - .collect::>() - .join("\n") - + "\n", - ) - .unwrap(); - for (id, blob) in payload.blobs { - fs::write(payload_root.join("blobs").join(id), blob).unwrap(); - } - (temp, session_root, payload_root, manifest) - } - - #[test] - fn verify_script_returns_only_manifest_after_local_blob_hash_validation() { - let (_temp, session_root, payload_root, expected) = fixture(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(parse_manifest_response(&output.stdout).unwrap(), expected); - - let blob = fs::read_dir(payload_root.join("blobs")) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - fs::write(blob, b"tampered").unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .output() - .unwrap(); - assert!(!output.status.success()); - } - - #[test] - fn acquire_script_builds_backend_local_full_tree_snapshot() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("scripts")).unwrap(); - fs::create_dir_all(source.join("assets")).unwrap(); - fs::create_dir_all(source.join(".git")).unwrap(); - fs::write(source.join("SKILL.md"), b"skill").unwrap(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); - fs::write(source.join("assets/data.bin"), [0, 1, 255]).unwrap(); - fs::write(source.join("metadata.json"), b"excluded").unwrap(); - fs::write(source.join(".git/config"), b"excluded").unwrap(); - fs::set_permissions( - source.join("scripts/run.sh"), - fs::Permissions::from_mode(0o755), - ) - .unwrap(); - let expected_payload = build_skill_payload(&source).unwrap(); - let expected_hash = compute_cli_project_hash_from_payload(&expected_payload).unwrap(); - let expected = expected_payload.manifest(); - let session_root = temp.path().join("skill-deck-source-session-2"); - let payload_root = session_root.join("payload-demo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("acquire") - .arg(&source) - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let acquired = parse_acquire_response(&output.stdout).unwrap(); - assert_eq!(acquired.manifest, expected); - assert_eq!(acquired.total_bytes, 5 + 10 + 3); - assert_eq!(acquired.computed_hash, expected_hash); - assert!(!payload_root.join(MANIFEST_FILE).exists()); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(FINALIZE_SCRIPT) - .arg("--") - .arg("finalize") - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&finalize_request(&acquired.manifest).unwrap()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-2") - .arg("--expected") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&expected_blob_list(&expected)) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert!(output.status.success()); - assert_eq!(parse_manifest_response(&output.stdout).unwrap(), expected); - } - - #[cfg(unix)] - #[test] - fn source_fingerprint_tracks_mode_content_and_safe_link_targets() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("scripts")).expect("scripts"); - fs::write(source.join("SKILL.md"), b"skill").expect("Skill"); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").expect("script"); - std::os::unix::fs::symlink("scripts/run.sh", source.join("run")).expect("internal link"); - - let fingerprint = || { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("fingerprint") - .arg(&source) - .output() - .expect("fingerprint script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_source_fingerprint(&output.stdout).expect("fingerprint response") - }; - - let initial = fingerprint(); - fs::set_permissions( - source.join("scripts/run.sh"), - fs::Permissions::from_mode(0o755), - ) - .expect("mode"); - let mode_changed = fingerprint(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\necho changed\n").expect("content"); - let content_changed = fingerprint(); - - assert_ne!(initial, mode_changed); - assert_ne!(mode_changed, content_changed); - } - - #[cfg(unix)] - #[test] - fn source_fingerprint_rejects_external_links() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).expect("source"); - fs::write(temp.path().join("outside"), b"outside").expect("outside"); - std::os::unix::fs::symlink(temp.path().join("outside"), source.join("external")) - .expect("external link"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ACQUIRE_SCRIPT) - .arg("--") - .arg("fingerprint") - .arg(&source) - .output() - .expect("fingerprint script"); - - assert!(!output.status.success()); - } - - #[test] - fn store_scripts_write_an_in_memory_payload_into_backend_local_storage() { - let temp = tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir_all(source.join("assets")).unwrap(); - fs::write(source.join("SKILL.md"), b"skill").unwrap(); - fs::write(source.join("assets/data.bin"), [0, 1, 2, 255]).unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let session_root = temp.path().join("skill-deck-source-session-3"); - let payload_root = session_root.join("payload-demo"); - - let begin = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_BEGIN_SCRIPT) - .arg("--") - .arg("store-begin") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .output() - .unwrap(); - assert!( - begin.status.success(), - "{}", - String::from_utf8_lossy(&begin.stderr) - ); - - for (blob_id, blob) in &payload.blobs { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_BLOB_SCRIPT) - .arg("--") - .arg("store-blob") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .arg(blob_id) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(blob).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(STORE_FINALIZE_SCRIPT) - .arg("--") - .arg("store-finalize") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&finalize_request(&payload.manifest()).unwrap()) - .unwrap(); - let finalized = child.wait_with_output().unwrap(); - assert!( - finalized.status.success(), - "{}", - String::from_utf8_lossy(&finalized.stderr) - ); - - let verified = Command::new("/bin/sh") - .arg("-c") - .arg(VERIFY_SCRIPT) - .arg("--") - .arg("verify") - .arg(&session_root) - .arg(&payload_root) - .arg("session-3") - .output() - .unwrap(); - assert!(verified.status.success()); - assert_eq!( - parse_manifest_response(&verified.stdout).unwrap(), - payload.manifest() - ); - for (blob_id, blob) in &payload.blobs { - assert_eq!( - fs::read(payload_root.join("blobs").join(blob_id)).unwrap(), - *blob - ); - } - } - - #[test] - fn blob_protocol_preserves_binary_content() { - let (_temp, session_root, payload_root, manifest) = fixture(); - let blob_id = manifest.entries[0].blob_id.as_deref().unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(READ_BLOB_SCRIPT) - .arg("--") - .arg("read-blob") - .arg(&session_root) - .arg(&payload_root) - .arg("session-1") - .arg(blob_id) - .output() - .unwrap(); - assert!(output.status.success()); - assert_eq!(parse_blob_response(&output.stdout).unwrap(), [0, 1, 2, 255]); - } - - #[test] - fn removal_script_requires_matching_owner_and_managed_child() { - let (temp, session_root, payload_root, _manifest) = fixture(); - let forged = temp.path().join("outside"); - fs::create_dir(&forged).unwrap(); - assert!(!run_remove(&session_root, &forged, "session-1")); - assert!(forged.is_dir()); - assert!(run_remove(&session_root, &payload_root, "session-1")); - assert!(!payload_root.exists()); - } - - #[test] - fn reconnect_sweep_preserves_protected_sessions_and_reports_invalid_roots() { - let temp = tempdir().expect("temp"); - let protected = temp.path().join("skill-deck-source-protected"); - let orphan = temp.path().join("skill-deck-source-orphan"); - let invalid = temp.path().join("skill-deck-source-invalid"); - for (root, id) in [(&protected, "protected"), (&orphan, "orphan")] { - fs::create_dir(root).expect("session root"); - fs::write(root.join(OWNER_FILE), format!("1\n{id}\n")).expect("owner"); - fs::write(root.join("payload.bin"), b"payload").expect("payload"); - } - fs::create_dir(&invalid).expect("invalid root"); - fs::write(invalid.join("payload.bin"), b"retained").expect("invalid payload"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(SWEEP_ORPHANS_SCRIPT) - .arg("--") - .arg("sweep-orphans") - .arg(temp.path()) - .arg("protected") - .output() - .expect("sweep script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let report = parse_cleanup_report(&output.stdout).expect("cleanup report"); - - assert_eq!(report.removed_sessions, 1); - assert_eq!(report.protected_sessions, 1); - assert!(report.capacity_blocked); - assert!(report.external_retained_bytes >= b"retained".len() as u64); - assert!(protected.is_dir()); - assert!(!orphan.exists()); - assert!(invalid.is_dir()); - } - - fn run_remove(session_root: &Path, payload_root: &Path, session_id: &str) -> bool { - Command::new("/bin/sh") - .arg("-c") - .arg(REMOVE_PAYLOAD_SCRIPT) - .arg("--") - .arg("remove-payload") - .arg(session_root) - .arg(payload_root) - .arg(session_id) - .status() - .unwrap() - .success() - } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::application::payload_session::PayloadLocalSource; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - fn storage() -> WslPayloadSessionStorage { - let runtime = crate::environment::wsl::WslRuntime::default(); - runtime.insert(session()); - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")) - } - - #[test] - fn local_source_is_an_opaque_backend_owned_wsl_path() { - let storage = storage(); - let key = PayloadStorageKey::new("session-1", "skills/demo"); assert_eq!( - storage.local_source(&key).expect("local source"), - PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: format!( - "/tmp/skill-deck-source-session-1/payload-{}", - digest("skills/demo") - ), - } + super::normalize_skill_revision_path("skills/demo/skill.md").unwrap(), + "skills/demo" ); - } - - #[test] - fn source_revision_parser_rejects_non_git_hashes() { assert_eq!( - parse_source_revision(format!("1\0{}\0", "A".repeat(40)).as_bytes()).unwrap(), - "a".repeat(40) + super::normalize_skill_revision_path("SKILL.md").unwrap(), + "" ); - assert!(parse_source_revision(format!("1\0{}\0", "a".repeat(39)).as_bytes()).is_err()); - assert!(parse_source_revision(format!("1\0{}z\0", "a".repeat(39)).as_bytes()).is_err()); - } -} - -#[cfg(test)] -mod capability_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::environment::wsl::{WslRuntime, WslSession}; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[tokio::test] - async fn payload_storage_from_an_old_cycle_cannot_start_an_operation_after_reenable() { - let runtime = WslRuntime::default(); - runtime.insert(session()); - let storage = - WslPayloadSessionStorage::new(runtime.workspace("Ubuntu").expect("enabled workspace")); - - let disable = runtime.begin_disable().expect("begin disable"); - disable - .wait_for_quiescence(Duration::from_secs(1)) - .await - .expect("quiescent runtime"); - disable.commit_disabled(); - runtime - .begin_enable() - .expect("begin enable") - .commit_enabled(); - - let error = storage - .source_metadata_fingerprint("/tmp/source") - .await - .expect_err("stale payload storage"); - - assert!(matches!(error, AppError::EnvironmentUnavailable { .. })); } } diff --git a/src-tauri/src/environment/wsl/operations/content_manifest.rs b/src-tauri/src/environment/wsl/operations/content_manifest.rs index d4f36982..e1e6f197 100644 --- a/src-tauri/src/environment/wsl/operations/content_manifest.rs +++ b/src-tauri/src/environment/wsl/operations/content_manifest.rs @@ -1,36 +1,27 @@ -use tokio::time::Duration; - use crate::core::mutation::CancellationSignal; use crate::environment::content_manifest::{ ContentManifest, ContentManifestRecord, ContentManifestTarget, }; use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{normalized_wsl_distro_name, EnvironmentRef}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_HEADER: &[u8] = b"SDCM 1\n"; -pub(crate) const CONTENT_MANIFEST_SCRIPT: &str = include_str!("../scripts/content-manifest.sh"); -const CONTENT_MANIFEST_OPERATION: WslOperationDescriptor = - wsl_operation("content-manifest", "inspect", CONTENT_MANIFEST_SCRIPT); +const MANIFEST_DEADLINE_MILLIS: u64 = 60_000; pub async fn inspect( - session: &WslSession, + workspace: &WslWorkspace, target: &ContentManifestTarget, cancellation: Option, ) -> Result { let expected_environment = EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + distro_name: workspace.distro_name().to_string(), }; if target.location.environment != expected_environment || !matches!( &target.key.backend, ExecutionBackend::WslPosix { distro_name } - if distro_name == &normalized_wsl_distro_name(&session.distro_name) + if distro_name == &normalized_wsl_distro_name(workspace.distro_name()) ) || !target.location.native_path.starts_with('/') { @@ -38,255 +29,122 @@ pub async fn inspect( path: target.location.native_path.clone(), }); } - inspect_path(session, &target.location.native_path, cancellation).await + inspect_path(workspace, &target.location.native_path, cancellation).await } pub(crate) async fn inspect_path( - session: &WslSession, + workspace: &WslWorkspace, path: &str, cancellation: Option, ) -> Result { + #[cfg(target_os = "linux")] + let _ = workspace; if !path.starts_with('/') { return Err(AppError::StorageUnsupported { path: path.to_string(), }); } - let output = WslOperationExecutor::execute( - &CONTENT_MANIFEST_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(60), - stdout_limit: 32 * 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_content_manifest(&output.stdout) -} - -pub fn parse_content_manifest(bytes: &[u8]) -> Result { - if !bytes.starts_with(PROTOCOL_HEADER) { - return Err(protocol_error()); - } - let mut cursor = PROTOCOL_HEADER.len(); - let mut records = Vec::new(); - loop { - let header = next_line(bytes, &mut cursor)?; - let header = std::str::from_utf8(header).map_err(|_| protocol_error())?; - if let Some(count) = header.strip_prefix("E ") { - if cursor != bytes.len() - || count.parse::().map_err(|_| protocol_error())? != records.len() - { - return Err(protocol_error()); - } - return ContentManifest::from_records(records); - } - let fields = header.split(' ').collect::>(); - if fields.len() != 5 || fields[0] != "R" { - return Err(protocol_error()); - } - let executable = match fields[2] { - "0" => false, - "1" => true, - _ => return Err(protocol_error()), + #[cfg(target_os = "linux")] + let response = linux_manifest_response(path, cancellation.as_ref())?; + #[cfg(not(target_os = "linux"))] + let response: environment_protocol::ManifestResponse = { + let message = environment_protocol::Message::BuildManifest { + request: environment_protocol::ManifestRequest { + root: path.to_string(), + deadline_millis: MANIFEST_DEADLINE_MILLIS, + }, }; - let path_length = fields[3].parse::().map_err(|_| protocol_error())?; - let data_length = fields[4].parse::().map_err(|_| protocol_error())?; - let record_end = cursor - .checked_add(path_length) - .and_then(|value| value.checked_add(data_length)) - .filter(|end| *end <= bytes.len()) - .ok_or_else(protocol_error)?; - let path_end = cursor + path_length; - let path = std::str::from_utf8(&bytes[cursor..path_end]).map_err(|_| protocol_error())?; - let data = - std::str::from_utf8(&bytes[path_end..record_end]).map_err(|_| protocol_error())?; - cursor = record_end; - let record = match fields[1] { - "d" if !executable && data.is_empty() => ContentManifestRecord::directory(path), - "f" => ContentManifestRecord::file(path, data, executable), - "l" if !executable => ContentManifestRecord::symlink(path, data), - _ => return Err(protocol_error()), + match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await? + } + None => workspace.request_worker_payload(message).await?, } - .map_err(|_| protocol_error())?; - records.push(record); - } + }; + let records = + response + .records + .into_iter() + .map(|record| { + let path = String::from_utf8(record.relative_path).map_err(|_| protocol_error())?; + match record.kind { + environment_protocol::ManifestRecordKind::Directory + if record.digest.is_none() + && !record.executable + && record.symlink_target.is_none() => + { + ContentManifestRecord::directory(path) + } + environment_protocol::ManifestRecordKind::File + if record.digest.is_some() && record.symlink_target.is_none() => + { + ContentManifestRecord::file( + path, + record.digest.expect("checked file digest"), + record.executable, + ) + } + environment_protocol::ManifestRecordKind::Symlink + if record.digest.is_none() && !record.executable => + { + let target = record.symlink_target.ok_or_else(protocol_error).and_then( + |target| String::from_utf8(target).map_err(|_| protocol_error()), + )?; + ContentManifestRecord::symlink(path, target) + } + _ => return Err(protocol_error()), + } + .map_err(|_| protocol_error()) + }) + .collect::, _>>()?; + ContentManifest::from_records(records) } -fn next_line<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], AppError> { - let end = bytes[*cursor..] - .iter() - .position(|byte| *byte == b'\n') - .map(|offset| *cursor + offset) - .ok_or_else(protocol_error)?; - let line = &bytes[*cursor..end]; - *cursor = end + 1; - Ok(line) +#[cfg(target_os = "linux")] +fn linux_manifest_response( + path: &str, + cancellation: Option<&CancellationSignal>, +) -> Result { + use std::os::unix::ffi::OsStrExt; + + let response = environment_engine::manifest::build_manifest_with_cancel( + &environment_engine::manifest::ManifestRequest { root: path.into() }, + || cancellation.is_some_and(CancellationSignal::is_cancelled), + ) + .map_err(|error| AppError::ExecutionFailed { + message: format!("Linux content manifest failed: {error}"), + })?; + Ok(environment_protocol::ManifestResponse { + records: response + .records + .into_iter() + .map(|record| environment_protocol::ManifestRecord { + relative_path: record.relative_path.as_os_str().as_bytes().to_vec(), + kind: match record.kind { + environment_engine::manifest::ManifestKind::Directory => { + environment_protocol::ManifestRecordKind::Directory + } + environment_engine::manifest::ManifestKind::File => { + environment_protocol::ManifestRecordKind::File + } + environment_engine::manifest::ManifestKind::Symlink => { + environment_protocol::ManifestRecordKind::Symlink + } + }, + digest: record.digest, + executable: record.executable, + symlink_target: record + .symlink_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL content manifest protocol response".to_string(), - } -} - -#[cfg(test)] -mod parser_tests { - use crate::environment::content_manifest::{ContentManifest, ContentManifestRecord}; - - use super::parse_content_manifest; - - fn fixture_bytes(records: &[(&str, &str, bool, &str)]) -> Vec { - let mut bytes = b"SDCM 1\n".to_vec(); - for (kind, path, executable, data) in records { - bytes.extend_from_slice( - format!( - "R {kind} {} {} {}\n", - u8::from(*executable), - path.len(), - data.len() - ) - .as_bytes(), - ); - bytes.extend_from_slice(path.as_bytes()); - bytes.extend_from_slice(data.as_bytes()); - } - bytes.extend_from_slice(format!("E {}\n", records.len()).as_bytes()); - bytes - } - - #[test] - fn wsl_records_use_the_same_rust_aggregate_hash_as_native_records() { - let digest = "a".repeat(64); - let bytes = fixture_bytes(&[ - ("l", "current", false, "run.sh"), - ("d", "empty", false, ""), - ("f", "run.sh", true, &digest), - ]); - let parsed = parse_content_manifest(&bytes).unwrap(); - let native = ContentManifest::from_records(vec![ - ContentManifestRecord::directory("empty").unwrap(), - ContentManifestRecord::file("run.sh", digest, true).unwrap(), - ContentManifestRecord::symlink("current", "run.sh").unwrap(), - ]) - .unwrap(); - - assert_eq!(parsed.hash(), native.hash()); - } - - #[test] - fn wsl_parser_rejects_unknown_versions_malformed_lengths_and_truncation() { - let digest = "a".repeat(64); - let valid = fixture_bytes(&[("f", "SKILL.md", false, &digest)]); - let mut wrong_version = valid.clone(); - wrong_version[5] = b'2'; - let malformed_length = b"SDCM 1\nR f 0 nope 64\n"; - let truncated = &valid[..valid.len() - 3]; - - assert!(parse_content_manifest(&wrong_version).is_err()); - assert!(parse_content_manifest(malformed_length).is_err()); - assert!(parse_content_manifest(truncated).is_err()); - } - - #[test] - fn wsl_parser_rejects_unsafe_relative_paths_and_record_count_mismatch() { - let digest = "a".repeat(64); - let unsafe_path = fixture_bytes(&[("f", "../SKILL.md", false, &digest)]); - let mut wrong_count = fixture_bytes(&[("f", "SKILL.md", false, &digest)]); - let footer = wrong_count.len() - 4; - wrong_count[footer..].copy_from_slice(b"E 2\n"); - - assert!(parse_content_manifest(&unsafe_path).is_err()); - assert!(parse_content_manifest(&wrong_count).is_err()); - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "内容清单协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod linux_shell_tests { - use std::fs; - use std::process::Command; - - use super::{parse_content_manifest, CONTENT_MANIFEST_SCRIPT}; - - #[test] - fn content_manifest_remains_readable_without_nul_safe_xargs() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::tempdir().expect("temporary content root"); - let content = temp.path().join("skill"); - fs::create_dir_all(&content).expect("content directory"); - fs::write(content.join("SKILL.md"), "# Demo\n").expect("skill content"); - - let commands = temp.path().join("commands"); - fs::create_dir_all(&commands).expect("command directory"); - let xargs = commands.join("xargs"); - fs::write(&xargs, "#!/bin/sh\nexit 1\n").expect("failing xargs"); - fs::set_permissions(&xargs, fs::Permissions::from_mode(0o755)).expect("xargs permissions"); - - let path = format!( - "{}:{}", - commands.display(), - std::env::var("PATH").unwrap_or_default() - ); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(CONTENT_MANIFEST_SCRIPT) - .arg("--") - .arg("inspect") - .arg(&content) - .env("PATH", path) - .output() - .expect("content manifest script"); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let manifest = parse_content_manifest(&output.stdout).expect("content manifest"); - assert_eq!(manifest.records().len(), 1); - } - - #[test] - fn wsl_script_emits_records_only_and_rust_computes_the_manifest_hash() { - use std::os::unix::fs::{symlink, PermissionsExt}; - - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("skill"); - fs::create_dir_all(root.join("empty")).unwrap(); - fs::write(root.join("run.sh"), b"#!/bin/sh\n").unwrap(); - let mut permissions = fs::metadata(root.join("run.sh")).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(root.join("run.sh"), permissions).unwrap(); - symlink("run.sh", root.join("current")).unwrap(); - fs::write(root.join("target\n"), b"target").unwrap(); - symlink("target\n", root.join("newline-target")).unwrap(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(CONTENT_MANIFEST_SCRIPT) - .arg("--") - .arg("inspect") - .arg(&root) - .output() - .unwrap(); - - assert!(output.status.success(), "{:?}", output.stderr); - let manifest = parse_content_manifest(&output.stdout) - .unwrap_or_else(|error| panic!("{error:?}: {:?}", output.stdout)); - assert_eq!(manifest.records().len(), 5); - assert!(manifest.records().iter().any(|record| { - record.relative_path == "newline-target" - && record.symlink_target.as_deref() == Some("target\n") - })); - assert_eq!(manifest.hash().as_str().len(), 64); - assert!(!String::from_utf8_lossy(&output.stdout).contains(manifest.hash().as_str())); + message: "invalid WSL Worker content manifest response".to_string(), } } diff --git a/src-tauri/src/environment/wsl/operations/directory_count.rs b/src-tauri/src/environment/wsl/operations/directory_count.rs index 7480c67c..951f32c1 100644 --- a/src-tauri/src/environment/wsl/operations/directory_count.rs +++ b/src-tauri/src/environment/wsl/operations/directory_count.rs @@ -1,15 +1,7 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -pub(crate) const DIRECTORY_COUNT_SCRIPT: &str = include_str!("../scripts/directory-count.sh"); -const DIRECTORY_COUNT_OPERATION: WslOperationDescriptor = - wsl_operation("directory-count", "inspect", DIRECTORY_COUNT_SCRIPT); +const DIRECTORY_COUNT_DEADLINE_MILLIS: u64 = 20_000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirectoryCountFact { @@ -18,129 +10,84 @@ pub struct DirectoryCountFact { pub truncated: bool, } -pub async fn inspect( - session: &WslSession, - paths: &[String], - limit: u32, -) -> Result, AppError> { - if paths.is_empty() || limit == 0 || paths.iter().any(|path| !path.starts_with('/')) { - return Err(AppError::Validation { - field: Some("directoryCount".to_string()), - message: "WSL directory count requires absolute paths and a positive limit".to_string(), - }); - } - let mut args = Vec::with_capacity(paths.len() + 1); - args.push(limit.to_string()); - args.extend(paths.iter().cloned()); - let output = WslOperationExecutor::execute( - &DIRECTORY_COUNT_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(20), - stdout_limit: paths.len().saturating_mul(16 * 1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - let facts = parse_directory_counts(&output.stdout, paths)?; - if facts.len() != paths.len() - || facts - .iter() - .zip(paths) - .any(|(fact, path)| fact.path != *path) - { - return Err(protocol_error()); - } - Ok(facts) -} - -pub fn parse_directory_counts( - bytes: &[u8], - expected_paths: &[String], -) -> Result, AppError> { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) != Some("1") { - return Err(protocol_error()); - } - let mut facts = Vec::new(); - let mut index = 1; - while index < records.len() { - if records.get(index).map(String::as_str) != Some("path") || index + 4 >= records.len() { - return Err(protocol_error()); +impl WslWorkspace { + pub(crate) async fn count_directory_entries( + &self, + paths: Vec, + limit: u32, + ) -> Result, AppError> { + if paths.is_empty() || limit == 0 || paths.iter().any(|path| !path.starts_with('/')) { + return Err(AppError::Validation { + field: Some("directoryCount".to_string()), + message: "WSL directory count requires absolute paths and a positive limit" + .to_string(), + }); } - let path = records[index + 1].clone(); - let (observed_count, truncated) = match records[index + 2].as_str() { - "none" if records[index + 3] == "0" && records[index + 4] == "0" => (None, false), - "count" => { - let count = records[index + 3] - .parse::() - .map_err(|_| protocol_error())?; - let truncated = match records[index + 4].as_str() { - "0" => false, - "1" => true, - _ => return Err(protocol_error()), - }; - (Some(count), truncated) - } - _ => return Err(protocol_error()), - }; - facts.push(DirectoryCountFact { - path, - observed_count, - truncated, - }); - index += 5; - } - if facts.len() != expected_paths.len() { - return Err(protocol_error()); - } - Ok(facts) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL directory count protocol response".to_string(), + let response: environment_protocol::DirectoryCountResponse = self + .request_worker_payload(environment_protocol::Message::CountDirectoryEntries { + request: environment_protocol::DirectoryCountRequest { + paths: paths.clone(), + limit, + deadline_millis: DIRECTORY_COUNT_DEADLINE_MILLIS, + }, + }) + .await?; + if response.facts.len() != paths.len() + || response + .facts + .iter() + .zip(&paths) + .any(|(fact, path)| fact.path != *path) + { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker directory count response".to_string(), + }); + } + Ok(response + .facts + .into_iter() + .map(|fact| DirectoryCountFact { + path: fact.path, + observed_count: fact.observed_count, + truncated: fact.truncated, + }) + .collect()) } -} -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "目录计数协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::process::Command; - - use super::{parse_directory_counts, DIRECTORY_COUNT_SCRIPT}; - - #[test] - fn versioned_directory_count_isolates_missing_paths_and_reports_counts() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join("skills/one")).unwrap(); - std::fs::create_dir_all(temp.path().join("skills/two")).unwrap(); - let skills = temp.path().join("skills").to_string_lossy().into_owned(); - let missing = temp.path().join("missing").to_string_lossy().into_owned(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(DIRECTORY_COUNT_SCRIPT) - .arg("--") - .arg("inspect") - .arg("10000") - .arg(&skills) - .arg(&missing) - .output() - .unwrap(); - - assert!(output.status.success()); - let facts = - parse_directory_counts(&output.stdout, &[skills.clone(), missing.clone()]).unwrap(); - assert_eq!(facts[0].path, skills); - assert_eq!(facts[0].observed_count, Some(2)); - assert!(!facts[0].truncated); - assert_eq!(facts[1].path, missing); - assert_eq!(facts[1].observed_count, None); + pub(crate) async fn list_child_directories( + &self, + path: String, + limit: u32, + ) -> Result, AppError> { + let response: environment_protocol::DirectoryListResponse = self + .request_worker_payload(environment_protocol::Message::ListChildDirectories { + request: environment_protocol::DirectoryListRequest { + path, + limit, + deadline_millis: DIRECTORY_COUNT_DEADLINE_MILLIS, + }, + }) + .await?; + if response.truncated { + return Err(AppError::ExecutionFailed { + message: "WSL child directory list exceeds its boundary".to_string(), + }); + } + response + .names + .into_iter() + .map(|name| { + let name = + String::from_utf8(name).map_err(|_| AppError::ConfigurationCorrupted { + message: "WSL child directory name is not UTF-8".to_string(), + })?; + if name.is_empty() || matches!(name.as_str(), "." | "..") || name.contains('/') { + return Err(AppError::ConfigurationCorrupted { + message: "WSL Worker returned an unsafe directory name".to_string(), + }); + } + Ok(name) + }) + .collect() } } diff --git a/src-tauri/src/environment/wsl/operations/entry.rs b/src-tauri/src/environment/wsl/operations/entry.rs index 96aaa304..40c0faac 100644 --- a/src-tauri/src/environment/wsl/operations/entry.rs +++ b/src-tauri/src/environment/wsl/operations/entry.rs @@ -1,19 +1,11 @@ use sha2::{Digest, Sha256}; -use tokio::time::Duration; use crate::core::mutation::CancellationSignal; use crate::environment::runtime::EntryFingerprint; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "1"; -pub(crate) const ENTRY_STATE_SCRIPT: &str = include_str!("../scripts/entry.sh"); -const ENTRY_STATE_OPERATION: WslOperationDescriptor = - wsl_operation("entry-state", "inspect", ENTRY_STATE_SCRIPT); +const ENTRY_DEADLINE_MILLIS: u64 = 10_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PosixEntryKind { @@ -34,205 +26,155 @@ pub struct PosixEntryState { } pub async fn inspect_entries( - session: &WslSession, + workspace: &WslWorkspace, paths: &[String], cancellation: Option, ) -> Result, AppError> { + #[cfg(target_os = "linux")] + let _ = workspace; if paths.is_empty() || paths.iter().any(|path| !path.starts_with('/')) { return Err(AppError::Validation { field: Some("entry.paths".to_string()), message: "WSL entry inspection requires absolute paths".to_string(), }); } - let output = WslOperationExecutor::execute( - &ENTRY_STATE_OPERATION, - WslOperationRequest { - session: session.clone(), - args: paths.to_vec(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: paths.len().saturating_mul(1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_entry_states(&output.stdout, paths.len()) -} - -pub fn parse_entry_states(bytes: &[u8], expected: usize) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != PROTOCOL_VERSION { - return Err(protocol_error()); - } - let mut states = Vec::with_capacity(expected); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "S" { - return Err(protocol_error()); - } - let index = parse::(fields.next())?; - let kind = match text(fields.next())? { - "missing" => PosixEntryKind::Missing, - "file" => PosixEntryKind::File, - "directory" => PosixEntryKind::Directory, - "symlink" => PosixEntryKind::Symlink, - "brokenLink" => PosixEntryKind::BrokenLink, - "other" => PosixEntryKind::Other, - _ => return Err(protocol_error()), + #[cfg(target_os = "linux")] + let response = linux_entry_response(paths, cancellation.as_ref())?; + #[cfg(not(target_os = "linux"))] + let response: environment_protocol::EntryFactsResponse = { + let message = environment_protocol::Message::InspectEntries { + request: environment_protocol::EntryFactsRequest { + paths: paths.to_vec(), + deadline_millis: ENTRY_DEADLINE_MILLIS, + }, }; - let device = text(fields.next())?; - let inode = text(fields.next())?; - let mode = text(fields.next())?; - let size = text(fields.next())?; - let mtime_seconds = text(fields.next())?; - let mtime_nanos = text(fields.next())?; - let link_target = optional_text(fields.next())?; - if index as usize != states.len() { - return Err(protocol_error()); - } - let fingerprint = if kind == PosixEntryKind::Missing { - if [device, inode, mode, size, mtime_seconds, mtime_nanos] - .iter() - .any(|value| !value.is_empty()) - || link_target.is_some() - { - return Err(protocol_error()); - } - EntryFingerprint("entry-v1-missing".to_string()) - } else { - if [device, inode, mode, size, mtime_seconds, mtime_nanos] - .iter() - .any(|value| value.is_empty()) - { - return Err(protocol_error()); + match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await? } - let mut hasher = Sha256::new(); - hasher.update(b"skill-deck-wsl-entry-v1\0"); - for value in [device, inode, mode, size, mtime_seconds, mtime_nanos] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - if let Some(target) = &link_target { - hasher.update(target.as_bytes()); - } - EntryFingerprint(format!("entry-v1-{:x}", hasher.finalize())) - }; - states.push(PosixEntryState { - index, - kind, - fingerprint, - link_target, - }); - } - if states.len() != expected { + None => workspace.request_worker_payload(message).await?, + } + }; + if response.facts.len() != paths.len() { return Err(protocol_error()); } - Ok(states) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) + response + .facts + .into_iter() + .enumerate() + .map(|(index, fact)| { + let kind = match fact.kind { + environment_protocol::EntryFactKind::Missing => PosixEntryKind::Missing, + environment_protocol::EntryFactKind::File => PosixEntryKind::File, + environment_protocol::EntryFactKind::Directory => PosixEntryKind::Directory, + environment_protocol::EntryFactKind::Symlink => PosixEntryKind::Symlink, + environment_protocol::EntryFactKind::BrokenLink => PosixEntryKind::BrokenLink, + environment_protocol::EntryFactKind::Other => PosixEntryKind::Other, + }; + let link_target = fact + .link_target + .map(String::from_utf8) + .transpose() + .map_err(|_| protocol_error())?; + let fingerprint = if kind == PosixEntryKind::Missing { + if fact.metadata.is_some() || link_target.is_some() { + return Err(protocol_error()); + } + EntryFingerprint("entry-v1-missing".to_string()) + } else { + let metadata = fact.metadata.ok_or_else(protocol_error)?; + let values = [ + metadata.device.to_string(), + metadata.inode.to_string(), + format!("{:x}", metadata.mode), + metadata.size.to_string(), + metadata.mtime_seconds.to_string(), + format!("{:09}", metadata.mtime_nanos), + ]; + let mut hasher = Sha256::new(); + hasher.update(b"skill-deck-wsl-entry-v1\0"); + for value in values { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + if let Some(target) = &link_target { + hasher.update(target.as_bytes()); + } + EntryFingerprint(format!("entry-v1-{:x}", hasher.finalize())) + }; + Ok(PosixEntryState { + index: index as u32, + kind, + fingerprint, + link_target, + }) + }) + .collect() } -fn optional_text(field: Option<&[u8]>) -> Result, AppError> { - let value = text(field)?; - Ok((!value.is_empty()).then(|| value.to_string())) -} +#[cfg(target_os = "linux")] +fn linux_entry_response( + paths: &[String], + cancellation: Option<&CancellationSignal>, +) -> Result { + use std::os::unix::ffi::OsStrExt; -fn parse(field: Option<&[u8]>) -> Result { - text(field)?.parse().map_err(|_| protocol_error()) + let response = environment_engine::entry::inspect_entries_with_cancel( + &environment_engine::entry::EntryRequest { + paths: paths.iter().map(Into::into).collect(), + }, + || cancellation.is_some_and(CancellationSignal::is_cancelled), + ) + .map_err(|error| AppError::ExecutionFailed { + message: format!("Linux entry inspection failed: {error}"), + })?; + Ok(environment_protocol::EntryFactsResponse { + facts: response + .facts + .into_iter() + .map(|fact| environment_protocol::EntryFact { + kind: match fact.kind { + environment_engine::entry::EntryKind::Missing => { + environment_protocol::EntryFactKind::Missing + } + environment_engine::entry::EntryKind::File => { + environment_protocol::EntryFactKind::File + } + environment_engine::entry::EntryKind::Directory => { + environment_protocol::EntryFactKind::Directory + } + environment_engine::entry::EntryKind::Symlink => { + environment_protocol::EntryFactKind::Symlink + } + environment_engine::entry::EntryKind::BrokenLink => { + environment_protocol::EntryFactKind::BrokenLink + } + environment_engine::entry::EntryKind::Other => { + environment_protocol::EntryFactKind::Other + } + }, + metadata: fact + .metadata + .map(|metadata| environment_protocol::EntryMetadata { + device: metadata.device, + inode: metadata.inode, + mode: metadata.mode, + size: metadata.size, + mtime_seconds: metadata.mtime_seconds, + mtime_nanos: metadata.mtime_nanos, + }), + link_target: fact + .link_target + .map(|target| target.as_os_str().as_bytes().to_vec()), + }) + .collect(), + }) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL entry state protocol response".to_string(), - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "目录项协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::fs; - use std::os::unix::fs::symlink; - use std::process::Command; - - use tempfile::tempdir; - - use super::*; - - fn inspect(paths: &[String]) -> Vec { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENTRY_STATE_SCRIPT) - .arg("--") - .arg("inspect") - .args(paths) - .output() - .expect("entry state script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_entry_states(&output.stdout, paths.len()).expect("parse") - } - - #[test] - fn fingerprint_is_stable_for_missing_and_changes_with_file_state() { - let temp = tempdir().expect("temp"); - let missing = temp.path().join("missing"); - let file = temp.path().join("file"); - fs::write(&file, b"first").unwrap(); - let paths = vec![ - missing.to_string_lossy().into_owned(), - file.to_string_lossy().into_owned(), - ]; - let before = inspect(&paths); - - assert_eq!(before[0].kind, PosixEntryKind::Missing); - assert_eq!(before[0].fingerprint.0, "entry-v1-missing"); - assert_eq!(before[1].kind, PosixEntryKind::File); - - fs::write(&file, b"second-longer").unwrap(); - let after = inspect(&paths); - assert_ne!(before[1].fingerprint, after[1].fingerprint); - } - - #[test] - fn final_symlink_is_fingerprinted_without_following_its_target() { - let temp = tempdir().expect("temp"); - let target = temp.path().join("target"); - let link = temp.path().join("link"); - fs::write(&target, b"first").unwrap(); - symlink(&target, &link).unwrap(); - let path = link.to_string_lossy().into_owned(); - let before = inspect(std::slice::from_ref(&path)); - - fs::write(&target, b"target changed").unwrap(); - let after = inspect(&[path]); - - assert_eq!(before[0].kind, PosixEntryKind::Symlink); - assert_eq!( - before[0].link_target.as_deref(), - Some(target.as_path().to_str().unwrap()) - ); - assert_eq!(before[0].fingerprint, after[0].fingerprint); - } - - #[test] - fn broken_final_symlink_has_an_explicit_kind() { - let temp = tempdir().expect("temp"); - let link = temp.path().join("broken"); - symlink(temp.path().join("missing-target"), &link).unwrap(); - - let state = inspect(&[link.to_string_lossy().into_owned()]); - - assert_eq!(state[0].kind, PosixEntryKind::BrokenLink); + message: "invalid WSL Worker entry response".to_string(), } } diff --git a/src-tauri/src/environment/wsl/operations/eve.rs b/src-tauri/src/environment/wsl/operations/eve.rs deleted file mode 100644 index 5548a9f7..00000000 --- a/src-tauri/src/environment/wsl/operations/eve.rs +++ /dev/null @@ -1,70 +0,0 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const EVE_PROJECT_SCRIPT: &str = include_str!("../scripts/eve.sh"); -const EVE_PROJECT_OPERATION: WslOperationDescriptor = - wsl_operation("eve-project", "inspect", EVE_PROJECT_SCRIPT); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EveProjectTargets { - pub has_eve: bool, - pub subagents: Vec, -} - -pub async fn inspect_eve_project( - session: &WslSession, - project_path: &str, -) -> Result { - let output = WslOperationExecutor::execute( - &EVE_PROJECT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![project_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - parse_eve_project(&output.stdout) -} - -pub fn parse_eve_project(bytes: &[u8]) -> Result { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) == Some("0") { - return Ok(EveProjectTargets { - has_eve: false, - subagents: Vec::new(), - }); - } - if records.first().map(String::as_str) != Some("1") { - return Err(protocol_error()); - } - let package: serde_json::Value = - serde_json::from_str(records.get(1).ok_or_else(protocol_error)?)?; - let has_eve = ["dependencies", "devDependencies"] - .into_iter() - .any(|section| { - package - .get(section) - .and_then(serde_json::Value::as_object) - .is_some_and(|dependencies| dependencies.contains_key("eve")) - }); - let mut subagents = records.into_iter().skip(2).collect::>(); - subagents.sort(); - Ok(EveProjectTargets { has_eve, subagents }) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL Eve project protocol response".to_string(), - } -} diff --git a/src-tauri/src/environment/wsl/operations/inspection.rs b/src-tauri/src/environment/wsl/operations/inspection.rs index 1c2759ca..46006186 100644 --- a/src-tauri/src/environment/wsl/operations/inspection.rs +++ b/src-tauri/src/environment/wsl/operations/inspection.rs @@ -3,26 +3,25 @@ use crate::environment::inspection::{ ReadPlan, ReadRootPurpose, }; use crate::environment::types::{same_environment_identity, EnvironmentRef}; -use crate::environment::wsl::operations::scan::{ - self, ScanRequest, ScanResponse, ScannedEntryKind, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; +const INSPECTION_DEADLINE_MILLIS: u64 = 30_000; + pub struct WslInspector { - session: WslSession, + workspace: WslWorkspace, } impl WslInspector { - pub fn new(session: WslSession) -> Self { - Self { session } + pub fn new(workspace: WslWorkspace) -> Self { + Self { workspace } } } impl FilesystemInspector for WslInspector { fn environment(&self) -> EnvironmentRef { EnvironmentRef::Wsl { - distro_name: self.session.distro_name.clone(), + distro_name: self.workspace.distro_name().to_string(), } } @@ -37,59 +36,100 @@ impl FilesystemInspector for WslInspector { path: "wslInspector".to_string(), }); } - let response = scan::scan( - &self.session, - ScanRequest { + let response = self + .workspace + .inspect_filesystem(environment_protocol::InspectionRequest { roots: plan .roots .iter() - .map(|root| root.locator.native_path.clone()) - .collect(), - stat_only_root_indexes: plan - .roots - .iter() - .enumerate() - .filter(|(_, root)| { - root.purposes.len() == 1 - && root.purposes.contains(&ReadRootPurpose::Context) + .map(|root| environment_protocol::InspectionRoot { + path: root.locator.native_path.clone(), + stat_only: root.purposes.len() == 1 + && root.purposes.contains(&ReadRootPurpose::Context), }) - .map(|(index, _)| index as u32) .collect(), - recursive: false, per_file_limit: plan.per_file_limit, aggregate_limit: plan.aggregate_limit, - }, - None, - ) - .await?; - snapshot_from_scan_response(environment, response) + deadline_millis: INSPECTION_DEADLINE_MILLIS, + }) + .await?; + snapshot_from_inspection_response(environment, response, plan.roots.len()) }) } } -pub fn snapshot_from_scan_response( +pub fn snapshot_from_inspection_response( environment: EnvironmentRef, - response: ScanResponse, + response: environment_protocol::InspectionResponse, + root_count: usize, ) -> Result { + let total_content_bytes = response + .facts + .iter() + .map(|fact| fact.content_bytes.len()) + .sum::(); + if total_content_bytes != response.total_content_bytes as usize { + return Err(worker_protocol_error( + "worker inspection content total does not match its facts", + )); + } let facts = response - .entries + .facts .into_iter() - .map(|entry| RawPathFact { - root_index: entry.root_index, - relative_path: entry.relative_path, - kind: match entry.kind { - ScannedEntryKind::Missing => FilesystemEntryKind::Missing, - ScannedEntryKind::File => FilesystemEntryKind::File, - ScannedEntryKind::Directory => FilesystemEntryKind::Directory, - ScannedEntryKind::Symlink => FilesystemEntryKind::Symlink, - ScannedEntryKind::Other | ScannedEntryKind::Error => FilesystemEntryKind::Other, - }, - resolved_target: entry.resolved_target, - frontmatter_bytes: entry.content_bytes, - truncated: entry.truncated, - error_code: entry.error_code, + .map(|fact| { + if fact.root_index as usize >= root_count { + return Err(worker_protocol_error( + "worker inspection root index is out of range", + )); + } + let relative_path = String::from_utf8(fact.relative_path) + .map_err(|_| worker_protocol_error("worker inspection path is not UTF-8"))?; + if relative_path.starts_with('/') + || relative_path.contains('\\') + || relative_path.split('/').any(|component| component == "..") + { + return Err(worker_protocol_error( + "worker inspection returned an unsafe relative path", + )); + } + let resolved_target = fact + .resolved_target + .map(String::from_utf8) + .transpose() + .map_err(|_| worker_protocol_error("worker link target is not UTF-8"))?; + Ok(RawPathFact { + root_index: fact.root_index, + relative_path, + kind: match fact.kind { + environment_protocol::InspectionEntryKind::Missing => { + FilesystemEntryKind::Missing + } + environment_protocol::InspectionEntryKind::File => FilesystemEntryKind::File, + environment_protocol::InspectionEntryKind::Directory => { + FilesystemEntryKind::Directory + } + environment_protocol::InspectionEntryKind::Symlink => { + FilesystemEntryKind::Symlink + } + environment_protocol::InspectionEntryKind::Other => FilesystemEntryKind::Other, + }, + resolved_target, + frontmatter_bytes: fact.content_bytes, + truncated: fact.truncated, + error_code: fact.error_code.map(|code| match code { + environment_protocol::InspectionErrorCode::PathUnavailable => { + "pathUnavailable".to_string() + } + environment_protocol::InspectionErrorCode::ReadFailed => { + "readFailed".to_string() + } + environment_protocol::InspectionErrorCode::ReadLinkFailed => { + "readLinkFailed".to_string() + } + }), + }) }) - .collect(); + .collect::, AppError>>()?; Ok(RawFilesystemSnapshot { environment, facts, @@ -97,95 +137,145 @@ pub fn snapshot_from_scan_response( }) } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "检查协议测试需要直接运行待验证的 shell 测试脚本" -)] +fn worker_protocol_error(message: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: message.to_string(), + } +} + +#[cfg(test)] mod tests { - use std::collections::BTreeSet; - use std::fs; - use std::process::Command; + use super::*; - use tempfile::tempdir; + #[test] + fn worker_snapshot_projects_bounded_filesystem_facts() { + let content = b"---\nname: demo\n---\n".to_vec(); + let response = environment_protocol::InspectionResponse { + facts: vec![environment_protocol::InspectionFact { + root_index: 0, + relative_path: b"demo/SKILL.md".to_vec(), + kind: environment_protocol::InspectionEntryKind::File, + resolved_target: None, + content_bytes: content.clone(), + truncated: false, + error_code: None, + }], + total_content_bytes: content.len() as u32, + }; - use super::*; - use crate::core::agent_definition::AgentId; - use crate::environment::inspection::{FilesystemInspector, ReadPlanBuilder, ReadRootPurpose}; - use crate::environment::native::inspection::NativeInspector; - use crate::environment::runtime::ContextSnapshotRevision; - use crate::environment::types::{ - EnvironmentRef, ResourceLocator, SkillLocation, SkillLocationRef, - }; - use crate::environment::wsl::operations::scan::{ - parse_scan_response, ScanRequest, SCAN_SCRIPT, - }; + let snapshot = snapshot_from_inspection_response(EnvironmentRef::Native, response, 1) + .expect("snapshot"); + + assert_eq!(snapshot.facts.len(), 1); + assert_eq!(snapshot.facts[0].relative_path, "demo/SKILL.md"); + assert_eq!(snapshot.facts[0].kind, FilesystemEntryKind::File); + assert_eq!(snapshot.total_content_bytes, content.len() as u32); + } + #[test] + fn worker_snapshot_rejects_unsafe_relative_paths() { + let response = environment_protocol::InspectionResponse { + facts: vec![environment_protocol::InspectionFact { + root_index: 0, + relative_path: b"../escape".to_vec(), + kind: environment_protocol::InspectionEntryKind::File, + resolved_target: None, + content_bytes: Vec::new(), + truncated: false, + error_code: None, + }], + total_content_bytes: 0, + }; + + assert!(snapshot_from_inspection_response(EnvironmentRef::Native, response, 1).is_err()); + } + + #[cfg(target_os = "windows")] + // Run from the repository root on Windows after preparing the exact Worker build: + // `$env:SKILL_DECK_TEST_WSL_DISTRO='Ubuntu'; cargo test --manifest-path src-tauri/Cargo.toml environment::wsl::operations::inspection::tests::real_wsl_worker_executes_a_skill_read_plan -- --ignored --exact` #[tokio::test] - async fn native_and_posix_protocol_project_the_same_filesystem_facts() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("demo")).unwrap(); - fs::write(root.join("demo/SKILL.md"), b"---\nname: demo\n---\nbody").unwrap(); - let missing = temp.path().join("missing"); - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, + #[ignore = "requires SKILL_DECK_TEST_WSL_DISTRO and a real WSL 2 distribution"] + async fn real_wsl_worker_executes_a_skill_read_plan() { + use std::time::Duration; + + use crate::environment::inspection::{ + FilesystemInspector, ReadPlanBuilder, ReadRootPurpose, }; - let mut builder = ReadPlanBuilder::new( - context, - "registry-1", - "environment-1", - ContextSnapshotRevision::parse("context-v1-parity").unwrap(), - ); - for (index, path) in [&missing, &root].into_iter().enumerate() { - builder - .add_root( - ResourceLocator { - environment: EnvironmentRef::Native, - native_path: path.to_string_lossy().into_owned(), - }, - ReadRootPurpose::Detection, - Some(AgentId::parse(format!("agent-{index}")).unwrap()), - ) - .unwrap(); - } - let plan = builder.build().unwrap(); - let native = NativeInspector::new(EnvironmentRef::Native) - .inspect(&plan) + use crate::environment::runtime::ContextSnapshotRevision; + use crate::environment::types::{ResourceLocator, SkillLocation, SkillLocationRef}; + use crate::environment::wsl::protocol::{ + WslCommandRequest, WslCommandRunner, DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, + }; + use crate::environment::wsl::WslRuntime; + + let distro_name = std::env::var("SKILL_DECK_TEST_WSL_DISTRO") + .expect("set SKILL_DECK_TEST_WSL_DISTRO to an installed WSL 2 distribution"); + let root = format!("/tmp/skill-deck-worker-inspection-{}", uuid::Uuid::new_v4()); + let runtime = WslRuntime::for_wsl_test(); + let session = runtime + .connect(&distro_name) .await - .expect("native inspect"); - - let request = ScanRequest { - roots: plan - .roots - .iter() - .map(|root| root.locator.native_path.clone()) - .collect(), - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: plan.per_file_limit, - aggregate_limit: plan.aggregate_limit, + .expect("connect WSL Worker"); + let setup = WslCommandRunner::run(WslCommandRequest { + session: session.clone(), + script: "set -eu\nroot=$1\nmkdir -p \"$root/demo\"\ncat > \"$root/demo/SKILL.md\"\n", + args: vec![root.clone()], + stdin: b"---\nname: demo\ndescription: Worker fixture\n---\n".to_vec(), + timeout: Duration::from_secs(10), + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await + .expect("create fixture"); + assert_eq!(setup.exit_code, Some(0)); + + let environment = EnvironmentRef::Wsl { + distro_name: distro_name.clone(), }; - let output = Command::new("/bin/sh") - .arg("-c") - .arg(SCAN_SCRIPT) - .arg("--") - .arg("scan") - .arg(request.per_file_limit.to_string()) - .arg(request.aggregate_limit.to_string()) - .arg("") - .arg("0") - .args(&request.roots) - .output() + let mut builder = ReadPlanBuilder::new( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + "registry-worker-test", + "environment-worker-test", + ContextSnapshotRevision::parse("context-v1-worker-test").unwrap(), + ); + builder + .add_root( + ResourceLocator { + environment, + native_path: root.clone(), + }, + ReadRootPurpose::Private, + None, + ) .unwrap(); - assert!(output.status.success()); - let posix = snapshot_from_scan_response( - EnvironmentRef::Native, - parse_scan_response(&output.stdout, &request).unwrap(), - ) - .expect("project response"); - - assert_eq!(native, posix); + let snapshot = WslInspector::new(runtime.workspace(&distro_name).unwrap()) + .inspect(&builder.build().unwrap()) + .await; + + let cleanup = WslCommandRunner::run(WslCommandRequest { + session, + script: "set -eu\ncase $1 in /tmp/skill-deck-worker-inspection-*) rm -rf -- \"$1\" ;; *) exit 64 ;; esac\n", + args: vec![root], + stdin: Vec::new(), + timeout: Duration::from_secs(10), + stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, + stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + cancellation: None, + }) + .await; + + let snapshot = snapshot.expect("read fixture through WSL Worker"); + cleanup.expect("clean fixture"); + let document = snapshot + .facts + .iter() + .find(|fact| fact.relative_path == "demo/SKILL.md") + .expect("Skill document fact"); + assert_eq!(document.kind, FilesystemEntryKind::File); + assert!(document.frontmatter_bytes.starts_with(b"---\nname: demo\n")); } } diff --git a/src-tauri/src/environment/wsl/operations/path.rs b/src-tauri/src/environment/wsl/operations/path.rs deleted file mode 100644 index c4d0e39c..00000000 --- a/src-tauri/src/environment/wsl/operations/path.rs +++ /dev/null @@ -1,158 +0,0 @@ -use tokio::time::Duration; - -use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const MAP_HOST_PATH_SCRIPT: &str = include_str!("../scripts/path.sh"); -const MAP_STORAGE_PATH_TO_HOST_SCRIPT: &str = include_str!("../scripts/path.sh"); -const MAP_HOST_PATH_OPERATION: WslOperationDescriptor = - wsl_operation("path", "map-host", MAP_HOST_PATH_SCRIPT); -const MAP_STORAGE_PATH_TO_HOST_OPERATION: WslOperationDescriptor = - wsl_operation("path", "map-storage-host", MAP_STORAGE_PATH_TO_HOST_SCRIPT); - -pub async fn map_host_bridge_path( - session: &WslSession, - host_path: &str, - cancellation: Option, -) -> Result { - if host_path.is_empty() || host_path.contains('\0') { - return Err(AppError::Validation { - field: Some("bridgePath".to_string()), - message: "Host bridge path is invalid".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &MAP_HOST_PATH_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![host_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 16 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_mapped_path(&output.stdout) -} - -pub fn parse_mapped_path(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() { - return Err(protocol_error()); - } - let path = std::str::from_utf8(fields[1]).map_err(|_| protocol_error())?; - if !path.starts_with('/') || path.contains('\0') { - return Err(protocol_error()); - } - Ok(path.to_string()) -} - -pub async fn map_storage_path_to_host( - session: &WslSession, - storage_path: &str, - cancellation: Option, -) -> Result { - if !storage_path.starts_with('/') || storage_path.contains('\0') { - return Err(AppError::Validation { - field: Some("storagePath".to_string()), - message: "WSL storage path must be absolute".to_string(), - }); - } - let output = WslOperationExecutor::execute( - &MAP_STORAGE_PATH_TO_HOST_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![storage_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 16 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_host_storage_path(&output.stdout) -} - -pub fn parse_host_storage_path(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() || fields[1].is_empty() { - return Err(protocol_error()); - } - let path = std::str::from_utf8(fields[1]).map_err(|_| protocol_error())?; - if path.contains('\0') { - return Err(protocol_error()); - } - Ok(path.to_string()) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL Host bridge path response".to_string(), - } -} - -#[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "路径映射协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - #[cfg(target_os = "linux")] - use std::fs; - #[cfg(target_os = "linux")] - use std::process::Command; - - #[cfg(target_os = "linux")] - use tempfile::tempdir; - - use super::*; - - #[cfg(target_os = "linux")] - #[test] - fn host_path_mapping_returns_only_a_versioned_absolute_posix_path() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().unwrap(); - let tool = temp.path().join("wslpath"); - fs::write(&tool, "#!/bin/sh\nprintf '/custom/c/Bridge Path\\n'\n").unwrap(); - fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap(); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(MAP_HOST_PATH_SCRIPT) - .arg("--") - .arg("map-host") - .arg(r"C:\Temp\Bridge Path") - .env("PATH", temp.path()) - .output() - .unwrap(); - assert!(output.status.success()); - assert_eq!( - parse_mapped_path(&output.stdout).unwrap(), - "/custom/c/Bridge Path" - ); - assert!(parse_mapped_path(b"1\0relative\0").is_err()); - assert!(parse_mapped_path(b"2\0/mnt/c/bridge\0").is_err()); - } - - #[test] - fn storage_path_mapping_returns_only_one_versioned_windows_path() { - assert_eq!( - parse_host_storage_path(b"1\0C:\\Code\\App\0").unwrap(), - r"C:\Code\App" - ); - assert_eq!( - parse_host_storage_path(b"1\0\\\\wsl.localhost\\Ubuntu\\home\\me\\app\0").unwrap(), - r"\\wsl.localhost\Ubuntu\home\me\app" - ); - assert!(parse_host_storage_path(b"2\0C:\\Code\\App\0").is_err()); - assert!(parse_host_storage_path(b"1\0\0").is_err()); - } -} diff --git a/src-tauri/src/environment/wsl/operations/path_metadata.rs b/src-tauri/src/environment/wsl/operations/path_metadata.rs index 00ca3b5c..576b2635 100644 --- a/src-tauri/src/environment/wsl/operations/path_metadata.rs +++ b/src-tauri/src/environment/wsl/operations/path_metadata.rs @@ -1,16 +1,9 @@ -use tokio::time::Duration; - -use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - decode_nul_records, wsl_operation, WslOperationDescriptor, WslOperationExecutor, - WslOperationRequest, DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::{WslSession, WslWorkspace}; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -pub(crate) const PATH_METADATA_SCRIPT: &str = include_str!("../scripts/path-metadata.sh"); -const PATH_METADATA_OPERATION: WslOperationDescriptor = - wsl_operation("path-metadata", "inspect", PATH_METADATA_SCRIPT); +const PATH_METADATA_DEADLINE_MILLIS: u64 = 20_000; +const EVE_PACKAGE_LIMIT: u32 = 1024 * 1024; +const PATH_METADATA_AGGREGATE_LIMIT: u32 = 8 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PathMetadataQuery { @@ -44,102 +37,88 @@ pub struct PathMetadataFact { pub content: PathMetadataContent, } -pub async fn inspect( - session: &WslSession, - queries: &[PathMetadataQuery], - cancellation: Option, -) -> Result, AppError> { - if queries.is_empty() || queries.iter().any(|query| !query.path.starts_with('/')) { - return Err(AppError::Validation { - field: Some("pathMetadata.queries".to_string()), - message: "WSL path metadata requires absolute paths".to_string(), - }); - } - let mut args = Vec::with_capacity(queries.len() * 2); - for query in queries { - args.push(query.path.clone()); - args.push(if query.inspect_content { "1" } else { "0" }.to_string()); - } - let output = WslOperationExecutor::execute( - &PATH_METADATA_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(20), - stdout_limit: queries.len().saturating_mul(1024 * 1024 + 1024), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - let facts = parse_path_metadata(&output.stdout)?; - if facts.len() != queries.len() - || facts - .iter() - .zip(queries) - .any(|(fact, query)| fact.path != query.path) - { - return Err(protocol_error()); - } - Ok(facts) -} - impl WslWorkspace { pub(crate) async fn inspect_path_metadata( &self, queries: Vec, - cancellation: Option, ) -> Result, AppError> { - self.with_session_retry(move |session| { - let queries = queries.clone(); - let cancellation = cancellation.clone(); - async move { inspect(&session, &queries, cancellation).await } - }) - .await - } -} - -pub fn parse_path_metadata(bytes: &[u8]) -> Result, AppError> { - let records = decode_nul_records(bytes); - if records.first().map(String::as_str) != Some("1") { - return Err(protocol_error()); - } - let mut facts = Vec::new(); - let mut index = 1; - while index < records.len() { - if records.get(index).map(String::as_str) != Some("path") || index + 4 >= records.len() { + if queries.is_empty() || queries.iter().any(|query| !query.path.starts_with('/')) { + return Err(AppError::Validation { + field: Some("pathMetadata.queries".to_string()), + message: "WSL path metadata requires absolute paths".to_string(), + }); + } + let expected_paths = queries + .iter() + .map(|query| query.path.clone()) + .collect::>(); + let response: environment_protocol::PathMetadataResponse = self + .request_worker_payload(environment_protocol::Message::InspectPaths { + request: environment_protocol::PathMetadataRequest { + queries: queries + .into_iter() + .map(|query| environment_protocol::PathMetadataQuery { + path: query.path, + content_limit: query.inspect_content.then_some(EVE_PACKAGE_LIMIT), + }) + .collect(), + aggregate_content_limit: PATH_METADATA_AGGREGATE_LIMIT, + deadline_millis: PATH_METADATA_DEADLINE_MILLIS, + }, + }) + .await?; + if response.facts.len() != expected_paths.len() + || response + .facts + .iter() + .zip(&expected_paths) + .any(|(fact, path)| fact.path != *path) + { return Err(protocol_error()); } - let kind = match records[index + 2].as_str() { - "missing" => PathMetadataKind::Missing, - "directory" => PathMetadataKind::Directory, - "symlink-directory" => PathMetadataKind::SymlinkDirectory, - "symlink-other" => PathMetadataKind::SymlinkOther, - "other" => PathMetadataKind::Other, - "broken-link" => PathMetadataKind::BrokenLink, - "inaccessible" => PathMetadataKind::Inaccessible, - _ => return Err(protocol_error()), - }; - let content = match records[index + 3].as_str() { - "none" => PathMetadataContent::NotRequested, - "eve-unreadable" => PathMetadataContent::Unreadable, - "eve-empty" => PathMetadataContent::Empty, - "eve" => PathMetadataContent::Bytes(records[index + 4].as_bytes().to_vec()), - _ => return Err(protocol_error()), - }; - facts.push(PathMetadataFact { - path: records[index + 1].clone(), - kind, - content, - }); - index += 5; + Ok(response + .facts + .into_iter() + .map(|fact| PathMetadataFact { + path: fact.path, + kind: match fact.kind { + environment_protocol::PathMetadataKind::Missing => PathMetadataKind::Missing, + environment_protocol::PathMetadataKind::Directory => { + PathMetadataKind::Directory + } + environment_protocol::PathMetadataKind::SymlinkDirectory => { + PathMetadataKind::SymlinkDirectory + } + environment_protocol::PathMetadataKind::SymlinkOther => { + PathMetadataKind::SymlinkOther + } + environment_protocol::PathMetadataKind::Other => PathMetadataKind::Other, + environment_protocol::PathMetadataKind::BrokenLink => { + PathMetadataKind::BrokenLink + } + environment_protocol::PathMetadataKind::Inaccessible => { + PathMetadataKind::Inaccessible + } + }, + content: match fact.content { + environment_protocol::PathMetadataContent::NotRequested => { + PathMetadataContent::NotRequested + } + environment_protocol::PathMetadataContent::Empty => PathMetadataContent::Empty, + environment_protocol::PathMetadataContent::Unreadable => { + PathMetadataContent::Unreadable + } + environment_protocol::PathMetadataContent::Bytes(bytes) => { + PathMetadataContent::Bytes(bytes) + } + }, + }) + .collect()) } - Ok(facts) } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL path metadata protocol response".to_string(), + message: "invalid WSL Worker path metadata response".to_string(), } } diff --git a/src-tauri/src/environment/wsl/operations/projection.rs b/src-tauri/src/environment/wsl/operations/projection.rs index 7fe7ec2b..f4d1873f 100644 --- a/src-tauri/src/environment/wsl/operations/projection.rs +++ b/src-tauri/src/environment/wsl/operations/projection.rs @@ -1,17 +1,8 @@ -use tokio::time::Duration; - use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "2"; -pub(crate) const PROJECT_TARGETS_SCRIPT: &str = include_str!("../scripts/projection.sh"); -const PROJECT_TARGETS_OPERATION: WslOperationDescriptor = - wsl_operation("projection", "project-targets", PROJECT_TARGETS_SCRIPT); +const PROJECTION_DEADLINE_MILLIS: u64 = 10_000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectedPosixTarget { @@ -24,7 +15,7 @@ pub struct ProjectedPosixTarget { } pub async fn project_targets( - session: &WslSession, + workspace: &WslWorkspace, destinations: &[String], cancellation: Option, ) -> Result, AppError> { @@ -34,209 +25,61 @@ pub async fn project_targets( message: "WSL target projection requires absolute destinations".to_string(), }); } - let output = WslOperationExecutor::execute( - &PROJECT_TARGETS_OPERATION, - WslOperationRequest { - session: session.clone(), - args: destinations.to_vec(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: destinations - .len() - .saturating_mul(16 * 1024) - .saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, + let message = environment_protocol::Message::ProjectTargets { + request: environment_protocol::ProjectionRequest { + destinations: destinations.to_vec(), + deadline_millis: PROJECTION_DEADLINE_MILLIS, }, - ) - .await?; - parse_projected_targets(&output.stdout, destinations.len()) -} - -pub fn parse_projected_targets( - bytes: &[u8], - expected: usize, -) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != PROTOCOL_VERSION { - return Err(protocol_error()); - } - let mut targets = Vec::with_capacity(expected); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "P" { - return Err(protocol_error()); + }; + let response: environment_protocol::ProjectionResponse = match cancellation { + Some(cancellation) => { + workspace + .request_worker_payload_with_cancellation(message, cancellation) + .await? } - let index = parse(fields.next())?; - let anchor_device = parse(fields.next())?; - let anchor_inode = parse(fields.next())?; - let physical_destination = text(fields.next())?.to_string(); - let relative = text(fields.next())?; - let storage_projection = text(fields.next())?.to_string(); - let relative_components = relative.split('/').map(str::to_string).collect::>(); - if index as usize != targets.len() - || !physical_destination.starts_with('/') - || storage_projection.trim().is_empty() - || relative_components.is_empty() - || relative_components - .iter() - .any(|component| component.is_empty() || matches!(component.as_str(), "." | "..")) - { - return Err(protocol_error()); - } - targets.push(ProjectedPosixTarget { - index, - anchor_device, - anchor_inode, - physical_destination, - relative_components, - storage_projection, - }); - } - if targets.len() != expected { + None => workspace.request_worker_payload(message).await?, + }; + if response.targets.len() != destinations.len() { return Err(protocol_error()); } - Ok(targets) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) -} - -fn parse(field: Option<&[u8]>) -> Result { - text(field)?.parse().map_err(|_| protocol_error()) + response + .targets + .into_iter() + .enumerate() + .map(|(index, target)| { + let physical_destination = + String::from_utf8(target.physical_destination).map_err(|_| protocol_error())?; + let relative_components = target + .relative_components + .into_iter() + .map(String::from_utf8) + .collect::, _>>() + .map_err(|_| protocol_error())?; + if !physical_destination.starts_with('/') + || target.storage_projection.trim().is_empty() + || relative_components.is_empty() + || relative_components.iter().any(|component| { + component.is_empty() + || matches!(component.as_str(), "." | "..") + || component.contains('/') + }) + { + return Err(protocol_error()); + } + Ok(ProjectedPosixTarget { + index: index as u32, + anchor_device: target.anchor_device, + anchor_inode: target.anchor_inode, + physical_destination, + relative_components, + storage_projection: target.storage_projection, + }) + }) + .collect() } fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: "invalid WSL target projection protocol response".to_string(), - } -} - -#[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "路径投影协议测试需要直接调用 wslpath 并运行 shell 测试脚本" -)] -mod tests { - #[cfg(target_os = "linux")] - use std::fs; - #[cfg(target_os = "linux")] - use std::process::{Command, Stdio}; - - #[cfg(target_os = "linux")] - use tempfile::tempdir; - - use super::*; - - #[cfg(target_os = "linux")] - fn wslpath_available() -> bool { - Command::new("wslpath") - .args(["-w", "/"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) - } - - #[test] - fn parser_preserves_host_and_wsl_storage_projection_evidence() { - let response = [ - "2", - "P", - "0", - "7", - "11", - "/mnt/c/work/Foo", - "Foo", - r"C:\work", - "P", - "1", - "8", - "12", - "/home/alice/foo", - "foo", - r"\\wsl.localhost\Ubuntu\home\alice", - ] - .join("\0"); - - let projected = parse_projected_targets(response.as_bytes(), 2).unwrap(); - - assert_eq!(projected[0].storage_projection, r"C:\work"); - assert_eq!( - projected[1].storage_projection, - r"\\wsl.localhost\Ubuntu\home\alice" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn protocol_projects_missing_roots_from_the_resolved_existing_ancestor() { - if !wslpath_available() { - return; - } - let temp = tempdir().unwrap(); - let destination = temp.path().join(".custom/skills/demo"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(PROJECT_TARGETS_SCRIPT) - .arg("--") - .arg("project-targets") - .arg(&destination) - .output() - .unwrap(); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(output.stdout.starts_with(b"2\0")); - let projected = parse_projected_targets(&output.stdout, 1).unwrap(); - assert_eq!(projected.len(), 1); - assert_eq!( - projected[0].physical_destination, - destination.to_string_lossy() - ); - assert_eq!( - projected[0].relative_components, - vec![".custom", "skills", "demo"] - ); - assert!(!projected[0].storage_projection.is_empty()); - assert!(!destination.parent().unwrap().exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn protocol_resolves_an_existing_symlink_before_appending_missing_components() { - if !wslpath_available() { - return; - } - use std::os::unix::fs::symlink; - - let temp = tempdir().unwrap(); - let physical = temp.path().join("physical"); - let logical = temp.path().join("logical"); - fs::create_dir(&physical).unwrap(); - symlink(&physical, &logical).unwrap(); - let destination = logical.join("skills/demo"); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(PROJECT_TARGETS_SCRIPT) - .arg("--") - .arg("project-targets") - .arg(&destination) - .output() - .unwrap(); - - assert!(output.status.success()); - let projected = parse_projected_targets(&output.stdout, 1).unwrap(); - assert_eq!( - projected[0].physical_destination, - physical.join("skills/demo").to_string_lossy() - ); - assert_eq!(projected[0].relative_components, vec!["skills", "demo"]); + message: "invalid WSL Worker target projection response".to_string(), } } diff --git a/src-tauri/src/environment/wsl/operations/scan.rs b/src-tauri/src/environment/wsl/operations/scan.rs index c37ebdd5..e1da08b9 100644 --- a/src-tauri/src/environment/wsl/operations/scan.rs +++ b/src-tauri/src/environment/wsl/operations/scan.rs @@ -1,19 +1,14 @@ use std::collections::BTreeSet; +use std::time::Duration; use serde::{Deserialize, Serialize}; -use tokio::time::Duration; use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::operations::source_acquisition::WslNativeSource; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const PROTOCOL_VERSION: &str = "2"; -pub(crate) const SCAN_SCRIPT: &str = include_str!("../scripts/scan.sh"); -const SCAN_OPERATION: WslOperationDescriptor = wsl_operation("scan", "scan", SCAN_SCRIPT); +const SCAN_RESPONSE_METADATA_ALLOWANCE: usize = 4 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -62,19 +57,24 @@ pub struct ScanResponse { } pub async fn scan( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, cancellation: Option, ) -> Result { - let mode = if request.recursive { "1" } else { "0" }; - execute_scan(session, request, mode, cancellation).await + execute_scan( + workspace, + source, + request, + environment_protocol::SourceScanMode::Recursive, + cancellation, + ) + .await } -/// 扫描 plugin manifest 声明目录的直接子 Skill。 -/// -/// 该 mode 不改变通用 ScanRequest,也不会回传目录中的普通 payload metadata。 pub async fn scan_priority_directories( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, cancellation: Option, ) -> Result { @@ -84,113 +84,117 @@ pub async fn scan_priority_directories( message: "priority directory scan must not enable recursive mode".to_string(), }); } - execute_scan(session, request, "2", cancellation).await + execute_scan( + workspace, + source, + request, + environment_protocol::SourceScanMode::PriorityDirectories, + cancellation, + ) + .await } async fn execute_scan( - session: &WslSession, + workspace: &WslWorkspace, + source: &WslNativeSource, request: ScanRequest, - mode: &str, + mode: environment_protocol::SourceScanMode, cancellation: Option, ) -> Result { validate_request(&request)?; - let mut args = vec![ - request.per_file_limit.to_string(), - request.aggregate_limit.to_string(), - stat_only_indexes_arg(&request.stat_only_root_indexes), - mode.to_string(), - ]; - args.extend(request.roots.iter().cloned()); - let metadata_allowance = 4usize * 1024 * 1024; - let stdout_limit = usize::try_from(request.aggregate_limit) - .unwrap_or(usize::MAX) - .saturating_add(metadata_allowance); - let output = WslOperationExecutor::execute( - &SCAN_OPERATION, - WslOperationRequest { - session: session.clone(), - args, - stdin: Vec::new(), - timeout: Duration::from_secs(30), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, + let handle = source.handle(); + let roots = request + .roots + .iter() + .enumerate() + .map(|(index, root)| { + Ok(environment_protocol::SourceScanRoot { + relative_path: relative_source_path(source.native_root(), root)?.into_bytes(), + stat_only: request + .stat_only_root_indexes + .contains(&u32::try_from(index).unwrap_or(u32::MAX)), + }) + }) + .collect::, AppError>>()?; + let response: environment_protocol::SourceScanResponse = workspace + .request_worker_payload_for_generation( + handle.generation, + environment_protocol::Message::ScanSource { + request: environment_protocol::SourceScanRequest { + source_id: handle.id, + roots, + mode, + per_file_limit: request.per_file_limit, + aggregate_limit: request.aggregate_limit, + deadline_millis: 30_000, + }, + }, + usize::try_from(request.aggregate_limit) + .unwrap_or(usize::MAX) + .saturating_add(SCAN_RESPONSE_METADATA_ALLOWANCE), cancellation, - }, - ) - .await?; - parse_scan_response(&output.stdout, &request) + Duration::from_secs(35), + ) + .await?; + Ok(ScanResponse { + entries: response + .entries + .into_iter() + .map(map_entry) + .collect::>()?, + root_count: u32::try_from(request.roots.len()).unwrap_or(u32::MAX), + total_content_bytes: response.total_content_bytes, + }) } -pub fn parse_scan_response(bytes: &[u8], request: &ScanRequest) -> Result { - validate_request(request)?; - let mut cursor = 0; - if read_text_field(bytes, &mut cursor)? != PROTOCOL_VERSION { - return Err(protocol_error("unsupported scan protocol version")); - } - let mut entries = Vec::new(); - let mut total_content_bytes = 0usize; - while cursor < bytes.len() { - if read_text_field(bytes, &mut cursor)? != "E" { - return Err(protocol_error("invalid scan record tag")); - } - let root_index = parse_field::(bytes, &mut cursor, "root index")?; - if usize::try_from(root_index).unwrap_or(usize::MAX) >= request.roots.len() { - return Err(protocol_error("scan root index is out of range")); - } - let relative_path = read_text_field(bytes, &mut cursor)?.to_string(); - validate_relative_path(&relative_path)?; - let kind = match read_text_field(bytes, &mut cursor)? { - "missing" => ScannedEntryKind::Missing, - "file" => ScannedEntryKind::File, - "directory" => ScannedEntryKind::Directory, - "symlink" => ScannedEntryKind::Symlink, - "other" => ScannedEntryKind::Other, - "error" => ScannedEntryKind::Error, - _ => return Err(protocol_error("invalid scanned entry kind")), - }; - let target = read_text_field(bytes, &mut cursor)?; - let resolved_target = (!target.is_empty()).then(|| target.to_string()); - let size = parse_field::(bytes, &mut cursor, "entry size")?; - let mode = parse_field::(bytes, &mut cursor, "entry mode")?; - let modified_seconds = parse_field::(bytes, &mut cursor, "entry modification time")?; - let truncated = match read_text_field(bytes, &mut cursor)? { - "0" => false, - "1" => true, - _ => return Err(protocol_error("invalid truncation flag")), - }; - let error = read_text_field(bytes, &mut cursor)?; - let error_code = (!error.is_empty()).then(|| error.to_string()); - let content_len = parse_field::(bytes, &mut cursor, "content length")?; - if content_len > request.per_file_limit as usize - || total_content_bytes.saturating_add(content_len) > request.aggregate_limit as usize - || cursor.saturating_add(content_len) >= bytes.len() - { - return Err(protocol_error("scan content length exceeds its boundary")); - } - let content_bytes = bytes[cursor..cursor + content_len].to_vec(); - cursor += content_len; - if bytes.get(cursor) != Some(&0) { - return Err(protocol_error("scan content terminator is missing")); - } - cursor += 1; - total_content_bytes += content_len; - entries.push(ScannedEntry { - root_index, - relative_path, - kind, - resolved_target, - size, - mode, - modified_seconds, - content_bytes, - truncated, - error_code, - }); - } - Ok(ScanResponse { - entries, - root_count: request.roots.len() as u32, - total_content_bytes: total_content_bytes as u32, +fn relative_source_path(source_root: &str, requested: &str) -> Result { + if requested == source_root { + return Ok(String::new()); + } + requested + .strip_prefix(source_root) + .and_then(|relative| relative.strip_prefix('/')) + .filter(|relative| { + !relative + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + }) + .map(str::to_string) + .ok_or_else(|| AppError::UnsafePath { + path: requested.to_string(), + reason: "WSL scan root is outside its Source handle".to_string(), + }) +} + +fn map_entry(entry: environment_protocol::SourceEntry) -> Result { + Ok(ScannedEntry { + root_index: entry.root_index, + relative_path: String::from_utf8(entry.relative_path).map_err(|_| protocol_error())?, + kind: match entry.kind { + environment_protocol::SourceEntryKind::Missing => ScannedEntryKind::Missing, + environment_protocol::SourceEntryKind::File => ScannedEntryKind::File, + environment_protocol::SourceEntryKind::Directory => ScannedEntryKind::Directory, + environment_protocol::SourceEntryKind::Symlink => ScannedEntryKind::Symlink, + environment_protocol::SourceEntryKind::Other => ScannedEntryKind::Other, + }, + resolved_target: entry + .link_target + .map(String::from_utf8) + .transpose() + .map_err(|_| protocol_error())?, + size: 0, + mode: 0, + modified_seconds: 0, + content_bytes: entry.content_bytes, + truncated: entry.truncated, + error_code: entry.error_code.map(|error| { + match error { + environment_protocol::SourceEntryErrorCode::PathUnavailable => "pathUnavailable", + environment_protocol::SourceEntryErrorCode::ReadFailed => "readFailed", + environment_protocol::SourceEntryErrorCode::ReadLinkFailed => "readLinkFailed", + } + .to_string() + }), }) } @@ -202,472 +206,34 @@ fn validate_request(request: &ScanRequest) -> Result<(), AppError> { || request .stat_only_root_indexes .iter() - .any(|index| *index as usize >= request.roots.len()) + .any(|index| usize::try_from(*index).unwrap_or(usize::MAX) >= request.roots.len()) { return Err(AppError::Validation { field: Some("scanRequest".to_string()), - message: "invalid bounded scan request".to_string(), + message: "invalid WSL scan request".to_string(), }); } Ok(()) } -fn stat_only_indexes_arg(indexes: &BTreeSet) -> String { - indexes - .iter() - .map(u32::to_string) - .collect::>() - .join(",") -} - -fn read_text_field<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a str, AppError> { - let remaining = bytes - .get(*cursor..) - .ok_or_else(|| protocol_error("scan cursor is out of range"))?; - let length = remaining - .iter() - .position(|byte| *byte == 0) - .ok_or_else(|| protocol_error("scan field terminator is missing"))?; - let field = std::str::from_utf8(&remaining[..length]) - .map_err(|_| protocol_error("scan field is not UTF-8"))?; - *cursor += length + 1; - Ok(field) -} - -fn parse_field(bytes: &[u8], cursor: &mut usize, name: &str) -> Result -where - T: std::str::FromStr, -{ - read_text_field(bytes, cursor)? - .parse() - .map_err(|_| protocol_error(&format!("invalid {name}"))) -} - -fn validate_relative_path(path: &str) -> Result<(), AppError> { - if path.starts_with('/') - || path.split('/').any(|component| component == "..") - || path.contains('\\') - { - return Err(protocol_error("unsafe relative path in scan response")); - } - Ok(()) -} - -fn protocol_error(message: &str) -> AppError { +fn protocol_error() -> AppError { AppError::ConfigurationCorrupted { - message: message.to_string(), + message: "WSL Worker scan response contains a non-UTF-8 path".to_string(), } } -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "扫描协议测试需要直接运行待验证的 shell 测试脚本" -)] +#[cfg(test)] mod tests { - use std::fs; - use std::process::Command; - - use tempfile::tempdir; - - use super::*; - - fn run_script_with_mode(request: &ScanRequest, mode: &str) -> ScanResponse { - let mut command = Command::new("/bin/sh"); - command - .arg("-c") - .arg(SCAN_SCRIPT) - .arg("--") - .arg("scan") - .arg(request.per_file_limit.to_string()) - .arg(request.aggregate_limit.to_string()) - .arg(stat_only_indexes_arg(&request.stat_only_root_indexes)) - .arg(mode) - .args(&request.roots); - let output = command.output().expect("run scan script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_scan_response(&output.stdout, request).expect("parse") - } - - fn run_script(request: &ScanRequest) -> ScanResponse { - run_script_with_mode(request, if request.recursive { "1" } else { "0" }) - } - - #[test] - fn one_batch_isolates_missing_root_and_reads_binary_skill_content() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("demo")).expect("demo"); - fs::write(root.join("demo/SKILL.md"), [b'a', 0, b'b', b'c']).expect("skill"); - let request = ScanRequest { - roots: vec![ - temp.path().join("missing").to_string_lossy().into_owned(), - root.to_string_lossy().into_owned(), - ], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 16, - aggregate_limit: 32, - }; - - let response = run_script(&request); - - assert_eq!(response.root_count, 2); - assert!(response - .entries - .iter() - .any(|entry| { entry.root_index == 0 && entry.kind == ScannedEntryKind::Missing })); - let skill = response - .entries - .iter() - .find(|entry| entry.root_index == 1 && entry.relative_path == "demo/SKILL.md") - .expect("skill entry"); - assert_eq!(skill.content_bytes, [b'a', 0, b'b', b'c']); - assert!(!skill.truncated); - } - - #[cfg(unix)] #[test] - fn default_scan_reads_skill_document_through_a_direct_child_directory_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempdir().expect("temp"); - let canonical = temp.path().join("canonical/toolkit"); - let agent_root = temp.path().join("agent-skills"); - fs::create_dir_all(&canonical).expect("canonical Skill"); - fs::create_dir_all(&agent_root).expect("Agent Skill directory"); - let document = b"---\nname: toolkit\ndescription: Toolkit\n---\n"; - fs::write(canonical.join("SKILL.md"), document).expect("Skill document"); - symlink(&canonical, agent_root.join("toolkit")).expect("Skill directory link"); - let request = ScanRequest { - roots: vec![agent_root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script(&request); - - let linked_directory = response - .entries - .iter() - .find(|entry| entry.relative_path == "toolkit") - .expect("linked Skill directory"); - assert_eq!(linked_directory.kind, ScannedEntryKind::Symlink); - let skill_document = response - .entries - .iter() - .find(|entry| entry.relative_path == "toolkit/SKILL.md") - .expect("Skill document through directory link"); - assert_eq!(skill_document.kind, ScannedEntryKind::File); - assert_eq!(skill_document.content_bytes, document); - } - - #[cfg(unix)] - #[test] - fn default_scan_does_not_read_through_a_broken_child_directory_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempdir().expect("temp"); - let agent_root = temp.path().join("agent-skills"); - fs::create_dir_all(&agent_root).expect("Agent Skill directory"); - symlink(temp.path().join("missing"), agent_root.join("toolkit")) - .expect("broken Skill directory link"); - let request = ScanRequest { - roots: vec![agent_root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script(&request); - - assert!(response.entries.iter().any(|entry| { - entry.relative_path == "toolkit" && entry.kind == ScannedEntryKind::Symlink - })); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "toolkit/SKILL.md")); - } - - #[test] - fn per_file_and_aggregate_limits_are_enforced_by_the_protocol() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("skills"); - fs::create_dir_all(root.join("first")).expect("first"); - fs::create_dir_all(root.join("second")).expect("second"); - fs::write(root.join("first/SKILL.md"), b"1234567890").expect("first skill"); - fs::write(root.join("second/SKILL.md"), b"abcdefghij").expect("second skill"); - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 6, - aggregate_limit: 8, - }); - let contents = response - .entries - .iter() - .filter(|entry| entry.relative_path.ends_with("SKILL.md")) - .collect::>(); + fn scan_roots_must_belong_to_the_source_handle() { assert_eq!( - contents - .iter() - .map(|entry| entry.content_bytes.len()) - .sum::(), - 8 + super::relative_source_path("/home/alice/repo", "/home/alice/repo/skills/demo") + .unwrap(), + "skills/demo" + ); + assert!(super::relative_source_path("/home/alice/repo", "/home/alice/other").is_err()); + assert!( + super::relative_source_path("/home/alice/repo", "/home/alice/repo/../other").is_err() ); - assert!(contents.iter().all(|entry| entry.truncated)); - assert_eq!(response.total_content_bytes, 8); - } - - #[test] - fn stat_only_root_does_not_enumerate_children_or_consume_content_budget() { - let temp = tempdir().expect("temp"); - fs::create_dir_all(temp.path().join("unrelated")).expect("unrelated"); - fs::write( - temp.path().join("unrelated/SKILL.md"), - b"---\nname: unrelated\ndescription: Unrelated\n---\n", - ) - .expect("skill"); - let response = run_script(&ScanRequest { - roots: vec![temp.path().to_string_lossy().into_owned()], - stat_only_root_indexes: [0].into_iter().collect(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert_eq!(response.entries.len(), 1); - assert_eq!(response.entries[0].relative_path, ""); - assert_eq!(response.total_content_bytes, 0); - } - - #[test] - fn recursive_scan_finds_nested_skill_files_without_changing_default_scan() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("packages/tools/demo")).expect("nested skill"); - fs::write( - root.join("packages/tools/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("skill"); - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "packages/tools/demo/SKILL.md")); - } - - #[test] - fn recursive_scan_returns_only_discovery_documents_and_reads_local_lock() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("skills/demo/scripts")).expect("skill tree"); - fs::create_dir_all(root.join(".claude-plugin")).expect("plugin directory"); - fs::write( - root.join("skills/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("skill"); - fs::write(root.join("skills/demo/scripts/run.sh"), b"#!/bin/sh\n").expect("script"); - fs::write( - root.join(".claude-plugin/plugin.json"), - br#"{"name":"demo","skills":["./skills/demo"]}"#, - ) - .expect("plugin"); - let lock = br#"{"version":1,"skills":{"demo":{}}}"#; - fs::write(root.join("skills-lock.json"), lock).expect("lock"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "skills/demo/scripts/run.sh")); - let lock_entry = response - .entries - .iter() - .find(|entry| entry.relative_path == "skills-lock.json") - .expect("lock entry"); - assert_eq!(lock_entry.content_bytes, lock); - } - - #[test] - fn recursive_scan_includes_skill_directory_at_cli_depth_five() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - let skill = root.join("one/two/three/four/five"); - fs::create_dir_all(&skill).expect("deep skill"); - fs::write( - skill.join("SKILL.md"), - b"---\nname: deep\ndescription: Deep\n---\n", - ) - .expect("skill"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "one/two/three/four/five/SKILL.md")); - } - - #[test] - fn priority_directory_scan_reads_only_direct_child_skill_documents() { - let temp = tempdir().expect("temp"); - let root = temp.path().join("plugin-catalog"); - fs::create_dir_all(root.join("direct/scripts")).expect("direct skill"); - fs::create_dir_all(root.join("category/nested")).expect("nested skill"); - fs::write( - root.join("direct/SKILL.md"), - b"---\nname: direct\ndescription: Direct\n---\n", - ) - .expect("direct document"); - fs::write(root.join("direct/scripts/run.sh"), b"#!/bin/sh\n").expect("ordinary file"); - fs::write( - root.join("category/nested/SKILL.md"), - b"---\nname: nested\ndescription: Nested\n---\n", - ) - .expect("nested document"); - let request = ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 1024, - aggregate_limit: 4096, - }; - - let response = run_script_with_mode(&request, "2"); - - assert!(response - .entries - .iter() - .any(|entry| entry.relative_path == "direct/SKILL.md")); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "direct/scripts")); - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "category/nested/SKILL.md")); - assert!(response.entries.iter().all(|entry| { - entry.relative_path.is_empty() - || entry - .relative_path - .rsplit('/') - .next() - .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md")) - })); - } - - #[cfg(unix)] - #[test] - fn recursive_scan_reports_plugin_documents_without_payload_metadata() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().expect("temp"); - let root = temp.path().join("repo"); - fs::create_dir_all(root.join(".claude-plugin")).expect("plugin directory"); - fs::create_dir_all(root.join("skills/demo")).expect("Skill directory"); - let executable = root.join("skills/demo/run.sh"); - fs::write(&executable, b"#!/bin/sh\n").expect("script"); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)) - .expect("executable mode"); - fs::write( - root.join("skills/demo/SKILL.md"), - b"---\nname: demo\ndescription: Demo\n---\n", - ) - .expect("Skill"); - let plugin_document = br#"{"name":"demo-plugin","skills":["./skills/demo"]}"#; - fs::write(root.join(".claude-plugin/plugin.json"), plugin_document).expect("plugin"); - - let response = run_script(&ScanRequest { - roots: vec![root.to_string_lossy().into_owned()], - stat_only_root_indexes: BTreeSet::new(), - recursive: true, - per_file_limit: 1024, - aggregate_limit: 4096, - }); - - assert!(!response - .entries - .iter() - .any(|entry| entry.relative_path == "skills/demo/run.sh")); - let plugin = response - .entries - .iter() - .find(|entry| entry.relative_path == ".claude-plugin/plugin.json") - .expect("plugin manifest entry"); - assert_eq!(plugin.content_bytes, plugin_document); - } - - #[test] - fn parser_rejects_unknown_protocol_and_truncated_content() { - let request = ScanRequest { - roots: vec!["/tmp".to_string()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 10, - aggregate_limit: 10, - }; - assert!(parse_scan_response(b"99\0", &request).is_err()); - assert!(parse_scan_response( - b"1\0E\0\x30\0SKILL.md\0file\0\x31\x30\0\x31\0\x35\0abc", - &request, - ) - .is_err()); - } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use std::collections::BTreeSet; - - use super::{parse_scan_response, ScanRequest}; - - #[test] - fn parser_rejects_unknown_protocol_and_truncated_content() { - let request = ScanRequest { - roots: vec!["/tmp".to_string()], - stat_only_root_indexes: BTreeSet::new(), - recursive: false, - per_file_limit: 10, - aggregate_limit: 10, - }; - assert!(parse_scan_response(b"99\0", &request).is_err()); - assert!(parse_scan_response( - b"1\0E\0\x30\0SKILL.md\0file\0\x31\x30\0\x31\0\x35\0abc", - &request, - ) - .is_err()); } } diff --git a/src-tauri/src/environment/wsl/operations/skill_content.rs b/src-tauri/src/environment/wsl/operations/skill_content.rs index 9cd231ee..e68bee3e 100644 --- a/src-tauri/src/environment/wsl/operations/skill_content.rs +++ b/src-tauri/src/environment/wsl/operations/skill_content.rs @@ -1,46 +1,18 @@ -use tokio::time::Duration; - -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::WslWorkspace; use crate::error::AppError; -const READ_SKILL_MARKDOWN_SCRIPT: &str = include_str!("../scripts/skill-content.sh"); -const SKILL_CONTENT_OPERATION: WslOperationDescriptor = - wsl_operation("skill-content", "read", READ_SKILL_MARKDOWN_SCRIPT); +const SKILL_MARKDOWN_LIMIT: u32 = 4 * 1024 * 1024; pub async fn read_skill_markdown( - session: &WslSession, + workspace: &WslWorkspace, canonical_path: &str, ) -> Result { - let output = match WslOperationExecutor::execute( - &SKILL_CONTENT_OPERATION, - WslOperationRequest { - session: session.clone(), - args: vec![canonical_path.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 4 * 1024 * 1024, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await - { - Ok(output) => output.stdout, - Err(AppError::WslCommandFailed { - exit_code: Some(44), - .. - }) => { - return Err(AppError::PathNotFound { - path: format!("{}/SKILL.md", canonical_path.trim_end_matches('/')), - }); - } - Err(error) => return Err(error), - }; - String::from_utf8(output).map_err(|error| AppError::InvalidSkillMd { + let path = format!("{}/SKILL.md", canonical_path.trim_end_matches('/')); + let bytes = workspace + .read_optional_document(path.clone(), SKILL_MARKDOWN_LIMIT) + .await? + .ok_or(AppError::PathNotFound { path })?; + String::from_utf8(bytes).map_err(|error| AppError::InvalidSkillMd { message: error.to_string(), }) } diff --git a/src-tauri/src/environment/wsl/operations/source_acquisition.rs b/src-tauri/src/environment/wsl/operations/source_acquisition.rs index 6f0b8b34..42609393 100644 --- a/src-tauri/src/environment/wsl/operations/source_acquisition.rs +++ b/src-tauri/src/environment/wsl/operations/source_acquisition.rs @@ -1,20 +1,10 @@ use tokio::time::Duration; -use uuid::Uuid; use crate::core::classify_git_failure; use crate::core::mutation::CancellationSignal; -use crate::environment::wsl::protocol::{ - no_wsl_exit_mapping, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, GIT_OUTPUT_CAPTURE, -}; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -const WSL_SOURCE_ACQUISITION_SCRIPT: &str = include_str!("../scripts/source-acquisition.sh"); -// 宿主监督额外覆盖发行版启动及超时后的进程清理,不改变 Git 的用户配置时限。 -const WSL_GIT_TRANSPORT_GRACE: Duration = Duration::from_secs(30); -const WSL_GIT_TIMEOUT_EXIT_CODE: i32 = 72; - #[derive(Debug, Clone, PartialEq, Eq)] pub enum WslAcquisitionSource { Git { @@ -26,128 +16,17 @@ pub enum WslAcquisitionSource { }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WslAcquisitionPlan { - pub script: &'static str, - pub subcommand: &'static str, - pub positional_args: Vec, - pub transport_timeout: Duration, - pub git_timeout: Duration, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct WslNativeSourcePlan { - native_root: String, - operation: Option, - cleanup_root: Option, -} - -fn build_wsl_native_source_plan( - session: &WslSession, - source: WslAcquisitionSource, - managed_repo_path: &str, - git_timeout: Duration, - proxy: Option<&str>, -) -> Result { - match source { - WslAcquisitionSource::Git { url, git_ref } => Ok(WslNativeSourcePlan { - native_root: managed_repo_path.to_string(), - operation: Some(WslAcquisitionPlan { - script: WSL_SOURCE_ACQUISITION_SCRIPT, - subcommand: "git", - positional_args: vec![ - url, - managed_repo_path.to_string(), - git_ref.unwrap_or_default(), - session.distro_name.clone(), - git_timeout.as_secs().to_string(), - if proxy.is_some() { - "inject" - } else { - "preserve" - } - .to_string(), - proxy.unwrap_or_default().to_string(), - ], - transport_timeout: git_timeout.saturating_add(WSL_GIT_TRANSPORT_GRACE), - git_timeout, - }), - cleanup_root: Some(managed_repo_path.to_string()), - }), - WslAcquisitionSource::Local { native_path } => { - if !native_path.starts_with('/') { - return Err(AppError::UnsafePath { - path: native_path, - reason: "WSL local Source must use an absolute POSIX path".to_string(), - }); - } - Ok(WslNativeSourcePlan { - native_root: native_path, - operation: None, - cleanup_root: None, - }) - } - } -} - -fn acquisition_cancelled() -> AppError { - AppError::MutationCancelled -} - -fn map_wsl_git_acquisition_error(error: AppError, source_url: &str, timeout: Duration) -> AppError { - match error { - AppError::WslCommandFailed { - exit_code: Some(WSL_GIT_TIMEOUT_EXIT_CODE), - .. - } => AppError::GitTimeout { - timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), - }, - AppError::WslCommandFailed { exit_code, stderr } => { - classify_git_failure(&stderr, source_url, "clone", exit_code) - } - AppError::WslCommandTimedOut => AppError::GitTimeout { - timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), - }, - other => other, - } -} - -async fn run_wsl_acquisition_plan_with( - session: WslSession, - plan: WslAcquisitionPlan, - cancellation: CancellationSignal, - runner: F, -) -> Result, AppError> -where - F: FnOnce( - WslSession, - &'static str, - &'static str, - Vec, - Duration, - CancellationSignal, - ) -> Fut, - Fut: std::future::Future, AppError>>, -{ - if cancellation.is_cancelled() { - return Err(acquisition_cancelled()); - } - runner( - session, - plan.script, - plan.subcommand, - plan.positional_args, - plan.transport_timeout, - cancellation, - ) - .await +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WorkerSourceHandle { + pub generation: u64, + pub id: u64, } #[derive(Debug)] pub struct WslNativeSource { workspace: WslWorkspace, + handle: Option, native_root: String, - cleanup_root: Option, managed_owner_registered: bool, ref_revision: Option, } @@ -160,803 +39,351 @@ impl WslNativeSource { pub fn ref_revision(&self) -> Option<&str> { self.ref_revision.as_deref() } + + pub(crate) fn handle(&self) -> WorkerSourceHandle { + self.handle + .expect("active WSL source must own a Worker handle") + } } impl Drop for WslNativeSource { fn drop(&mut self) { - let Some(native_root) = self.cleanup_root.take() else { - return; - }; - self.workspace.defer_source_cleanup(native_root); + if let Some(handle) = self.handle.take() { + self.workspace.defer_worker_source_release(handle); + } if self.managed_owner_registered { self.workspace.release_source_owner(); } } } -pub(crate) async fn cleanup_wsl_source( - session: &WslSession, - native_root: &str, -) -> Result<(), AppError> { - let descriptor = WslOperationDescriptor { - subcommand: "cleanup", - script: WSL_SOURCE_ACQUISITION_SCRIPT, - map_exit: no_wsl_exit_mapping, - }; - WslOperationExecutor::execute( - &descriptor, - WslOperationRequest { - session: session.clone(), - args: vec![native_root.to_string()], - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: 64, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - Ok(()) -} - pub async fn acquire_wsl_source_native( workspace: WslWorkspace, - session: &WslSession, + _session: &WslSession, source: WslAcquisitionSource, git_timeout: Duration, proxy: Option, cancellation: CancellationSignal, ) -> Result { - let managed_root = format!("/tmp/skill-deck-discovery-{}/repo", Uuid::new_v4().simple()); - let source_for_plan = source.clone(); - let initial_plan = build_wsl_native_source_plan( - session, - source_for_plan, - &managed_root, - git_timeout, - proxy.as_deref(), - )?; - let ref_revision = if let Some(operation) = initial_plan.operation.clone() { - let source_url = operation - .positional_args - .first() - .cloned() - .ok_or_else(acquisition_protocol_error)?; - let response = run_wsl_acquisition_plan_with( - session.clone(), - operation, - cancellation.clone(), - |session, script, subcommand, positional_args, timeout, cancellation| async move { - let descriptor = WslOperationDescriptor { - subcommand, - script, - map_exit: no_wsl_exit_mapping, - }; - WslOperationExecutor::execute_with_output_capture( - &descriptor, - WslOperationRequest { - session, - args: positional_args, - stdin: Vec::new(), - timeout, - stdout_limit: DEFAULT_WSL_STDOUT_LIMIT, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: Some(cancellation), - }, - GIT_OUTPUT_CAPTURE, - ) - .await - .map(|output| output.stdout) + let (message, source_url) = match source { + WslAcquisitionSource::Git { url, git_ref } => ( + environment_protocol::Message::AcquireGitSource { + request: environment_protocol::GitSourceRequest { + url: url.clone(), + git_ref, + proxy, + deadline_millis: duration_millis(git_timeout), + }, }, + Some(url), + ), + WslAcquisitionSource::Local { native_path } => { + if !native_path.starts_with('/') { + return Err(AppError::UnsafePath { + path: native_path, + reason: "WSL local Source must use an absolute POSIX path".to_string(), + }); + } + ( + environment_protocol::Message::OpenLocalSource { + request: environment_protocol::OpenLocalSourceRequest { path: native_path }, + }, + None, + ) + } + }; + let (generation, response) = workspace + .request_worker_control_once( + message, + Some(cancellation), + git_timeout.saturating_add(Duration::from_secs(5)), ) .await - .map_err(|error| map_wsl_git_acquisition_error(error, &source_url, git_timeout))?; - Some(parse_wsl_git_acquisition_response(&response)?) - } else if cancellation.is_cancelled() { - return Err(acquisition_cancelled()); - } else { - None + .map_err(|error| map_transport_timeout(error, git_timeout))?; + let (id, native_root, ref_revision) = match response { + environment_protocol::Message::SourceOpened { + source_id, + root, + revision, + } => (source_id, root, revision), + environment_protocol::Message::Error { + code, + phase, + parameters, + } if source_url.is_some() => { + return Err(map_git_error( + &code, + &phase, + ¶meters, + source_url.as_deref().unwrap_or_default(), + "clone", + git_timeout, + )); + } + environment_protocol::Message::Error { code, phase, .. } => { + return Err(AppError::ExecutionFailed { + message: format!("WSL Worker source request failed during {phase}: {code}"), + }); + } + _ => return Err(protocol_error("invalid WSL Worker SourceOpened response")), }; - let managed_owner_registered = initial_plan.cleanup_root.is_some(); - if managed_owner_registered { - workspace.register_source_owner()?; - } + workspace.register_source_owner()?; Ok(WslNativeSource { workspace, - native_root: initial_plan.native_root, - cleanup_root: initial_plan.cleanup_root, - managed_owner_registered, + handle: Some(WorkerSourceHandle { generation, id }), + native_root, + managed_owner_registered: true, ref_revision, }) } -fn parse_wsl_git_acquisition_response(bytes: &[u8]) -> Result { - let fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.len() != 3 || fields[0] != b"1" || !fields[2].is_empty() { - return Err(acquisition_protocol_error()); - } - let revision = std::str::from_utf8(fields[1]).map_err(|_| acquisition_protocol_error())?; - if !matches!(revision.len(), 40 | 64) || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err(acquisition_protocol_error()); - } - Ok(revision.to_ascii_lowercase()) -} - -fn acquisition_protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL source acquisition response".to_string(), - } -} - pub(crate) async fn probe_wsl_git_connection( - session: &WslSession, + workspace: &WslWorkspace, url: &str, proxy: Option, timeout: Duration, cancellation: CancellationSignal, ) -> Result<(), AppError> { - let source_url = url.to_string(); - let descriptor = WslOperationDescriptor { - subcommand: "git-probe", - script: WSL_SOURCE_ACQUISITION_SCRIPT, - map_exit: no_wsl_exit_mapping, - }; - let response = WslOperationExecutor::execute_with_output_capture( - &descriptor, - WslOperationRequest { - session: session.clone(), - args: vec![ - source_url.clone(), - session.distro_name.clone(), - timeout.as_secs().max(1).to_string(), - if proxy.is_some() { - "inject" - } else { - "preserve" - } - .to_string(), - proxy.clone().unwrap_or_default(), - ], - stdin: Vec::new(), - timeout, - stdout_limit: 256, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: Some(cancellation), - }, - GIT_OUTPUT_CAPTURE, - ) - .await - .map(|output| output.stdout) - .map_err(|error| map_wsl_git_acquisition_error(error, &source_url, timeout))?; - parse_wsl_git_acquisition_response(&response)?; - Ok(()) -} - -#[cfg(test)] -#[allow( - clippy::disallowed_methods, - reason = "acquisition 流程测试需要直接调用真实 Git 并运行 shell 测试脚本" -)] -mod tests { - use super::{ - build_wsl_native_source_plan, run_wsl_acquisition_plan_with, WslAcquisitionPlan, - WslAcquisitionSource, WslNativeSource, - }; - use crate::core::mutation::CancellationSignal; - use crate::environment::wsl::{WslRuntime, WslSession}; - use std::collections::BTreeMap; - #[cfg(unix)] - use std::fs; - #[cfg(target_os = "linux")] - use std::os::unix::fs::PermissionsExt; - #[cfg(unix)] - use std::process::Command; - - #[cfg(target_os = "linux")] - fn git(cwd: &std::path::Path, args: &[&str]) -> String { - let output = Command::new("git") - .current_dir(cwd) - .args(args) - .output() - .expect("git command"); - assert!( - output.status.success(), - "git {args:?}: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).unwrap().trim().to_string() - } - - #[test] - fn source_acquisition_response_requires_versioned_git_head() { - let revision_40 = "a".repeat(40); - let revision_64 = "b".repeat(64); - assert_eq!( - super::parse_wsl_git_acquisition_response(format!("1\0{revision_40}\0").as_bytes()) - .unwrap(), - revision_40 - ); - assert_eq!( - super::parse_wsl_git_acquisition_response(format!("1\0{revision_64}\0").as_bytes()) - .unwrap(), - revision_64 - ); - for invalid in [ - b"2\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0".as_slice(), - b"1\0short\0".as_slice(), - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0extra\0".as_slice(), - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".as_slice(), - ] { - assert!(super::parse_wsl_git_acquisition_response(invalid).is_err()); - } - } - - #[test] - fn dropping_native_source_only_defers_cleanup_to_the_runtime() { - let runtime = WslRuntime::default(); - let workspace = runtime.workspace("Ubuntu").expect("enabled workspace"); - let source = WslNativeSource { - workspace: workspace.clone(), - native_root: "/tmp/skill-deck-source/repo".to_string(), - cleanup_root: Some("/tmp/skill-deck-source".to_string()), - managed_owner_registered: false, - ref_revision: None, - }; - - drop(source); - - assert_eq!(workspace.deferred_source_cleanup_count(), 1); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_reports_cloned_head_even_if_source_advances_after_clone() { - let temp = tempfile::tempdir().expect("temp"); - let source = temp.path().join("source"); - fs::create_dir(&source).expect("source"); - git(&source, &["init", "-b", "main"]); - git(&source, &["config", "user.email", "test@example.com"]); - git(&source, &["config", "user.name", "Skill Deck Test"]); - fs::write(source.join("SKILL.md"), b"first").expect("first"); - git(&source, &["add", "SKILL.md"]); - git(&source, &["commit", "-m", "first"]); - let cloned_revision = git(&source, &["rev-parse", "HEAD"]); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg(&source) - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .arg("preserve") - .arg("") - .output() - .expect("acquisition script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let reported = super::parse_wsl_git_acquisition_response(&output.stdout).unwrap(); - - fs::write(source.join("SKILL.md"), b"second").expect("second"); - git(&source, &["add", "SKILL.md"]); - git(&source, &["commit", "-m", "second"]); - let advanced_revision = git(&source, &["rev-parse", "HEAD"]); - - assert_eq!(reported, cloned_revision); - assert_ne!(reported, advanced_revision); - fs::remove_dir_all(managed_root).expect("cleanup managed source"); - } - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu-24.04".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - fn configured_git_timeout() -> tokio::time::Duration { - tokio::time::Duration::from_secs(300) - } - - fn preserve_proxy() -> Option<&'static str> { - None - } - - fn git_operation() -> WslAcquisitionPlan { - build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), - git_ref: None, - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect("build Git source plan") - .operation - .expect("Git Source requires acquisition") - } - - #[test] - fn wsl_git_plan_keeps_source_and_ref_as_positional_arguments() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "$(touch /tmp/not-shell-source)".to_string(), - git_ref: Some("feature; echo unsafe".to_string()), + let (_, response) = workspace + .request_worker_control_once( + environment_protocol::Message::ProbeGit { + request: environment_protocol::GitSourceRequest { + url: url.to_string(), + git_ref: None, + proxy, + deadline_millis: duration_millis(timeout), + }, }, - "/mnt/c/Temp/sd-1/repo", - configured_git_timeout(), - preserve_proxy(), + Some(cancellation), + timeout.saturating_add(Duration::from_secs(5)), ) - .expect("build git plan"); - - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!( - operation.positional_args[0], - "$(touch /tmp/not-shell-source)" - ); - assert_eq!(operation.positional_args[1], "/mnt/c/Temp/sd-1/repo"); - assert_eq!(operation.positional_args[2], "feature; echo unsafe"); - assert!(!operation.script.contains("$(touch /tmp/not-shell-source)")); - assert!(!operation.script.contains("feature; echo unsafe")); + .await + .map_err(|error| map_transport_timeout(error, timeout))?; + match response { + environment_protocol::Message::GitProbed { .. } => Ok(()), + environment_protocol::Message::Error { + code, + phase, + parameters, + } => Err(map_git_error( + &code, + &phase, + ¶meters, + url, + "ls-remote", + timeout, + )), + _ => Err(protocol_error("invalid WSL Worker GitProbed response")), } +} - #[test] - fn wsl_git_plan_uses_the_configured_git_timeout() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), - git_ref: None, - }, - "/mnt/c/Temp/sd-1/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect("build Git source plan"); - - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!(operation.git_timeout, configured_git_timeout()); - assert_eq!( - operation.transport_timeout, - configured_git_timeout() + super::WSL_GIT_TRANSPORT_GRACE - ); - assert_eq!(operation.positional_args[4], "300"); +fn map_git_error( + code: &str, + phase: &str, + parameters: &[(String, String)], + url: &str, + operation: &str, + timeout: Duration, +) -> AppError { + if code == "deadlineExceeded" { + return AppError::GitTimeout { + timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), + }; } - - #[test] - fn wsl_git_failures_use_shared_git_error_semantics() { - let url = "https://alice:secret@github.com/acme/private.git?token=query-secret"; - let git_failure = super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: format!( - "fatal: failed to clone {url}\nAuthorization: Bearer header-secret" - ), - }, + let parameter = |name: &str| { + parameters + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + if code == "gitFailed" { + let exit_code = parameter("exitCode").and_then(|value| value.parse().ok()); + return classify_git_failure( + parameter("stderr").unwrap_or_default(), url, - configured_git_timeout(), + operation, + exit_code, ); - let rendered = git_failure.to_string(); - assert!(matches!( - git_failure, - crate::error::AppError::GitCloneFailed { .. } - )); - assert!(rendered.contains(url)); - assert!(rendered.contains("Authorization: Bearer header-secret")); - - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: "Could not resolve host: github.com".to_string(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitNetworkError { .. } - )); - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandTimedOut, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitTimeout { timeout_secs: 300 } - )); - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(72), - stderr: String::new(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitTimeout { timeout_secs: 300 } - )); } - - #[test] - fn wsl_git_proxy_failures_use_the_shared_network_error() { - for stderr in [ - "Could not resolve proxy: proxy.example", - "Failed to connect to 127.0.0.1 port 7890: Couldn't connect to server", - ] { - assert!(matches!( - super::map_wsl_git_acquisition_error( - crate::error::AppError::WslCommandFailed { - exit_code: Some(68), - stderr: stderr.to_string(), - }, - "https://github.com/acme/private.git", - configured_git_timeout(), - ), - crate::error::AppError::GitNetworkError { .. } - )); - } + if code == "gitUnavailable" { + return AppError::GitCloneFailed { + message: parameter("message") + .unwrap_or("Git is not available in the selected WSL distribution") + .to_string(), + }; } - - #[test] - fn wsl_local_source_is_read_directly_without_a_managed_copy() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Local { - native_path: "/home/alice/code/skills".to_string(), - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect("build local plan"); - - assert_eq!(plan.native_root, "/home/alice/code/skills"); - assert!(plan.operation.is_none()); - assert!(plan.cleanup_root.is_none()); - } - - #[test] - fn wsl_local_source_requires_an_absolute_posix_path() { - let error = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Local { - native_path: "relative/skills".to_string(), - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - preserve_proxy(), - ) - .expect_err("relative WSL Source must be rejected"); - - assert!(matches!(error, crate::error::AppError::UnsafePath { .. })); + AppError::ExecutionFailed { + message: format!("WSL Worker Git request failed during {phase}: {code}"), } +} - #[test] - fn wsl_git_plan_injects_proxy_only_for_the_current_command() { - let plan = build_wsl_native_source_plan( - &session(), - WslAcquisitionSource::Git { - url: "https://github.com/example/repo".to_string(), - git_ref: None, - }, - "/tmp/skill-deck-discovery-123/repo", - configured_git_timeout(), - Some("http://127.0.0.1:7890"), - ) - .expect("build proxied Git source plan"); +fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()) + .unwrap_or(u64::MAX) + .max(1) +} - let operation = plan.operation.expect("Git Source requires acquisition"); - assert_eq!(operation.positional_args[5], "inject"); - assert_eq!(operation.positional_args[6], "http://127.0.0.1:7890"); - assert!(operation - .script - .contains("git -c \"http.proxy=$proxy_url\"")); +fn map_transport_timeout(error: AppError, timeout: Duration) -> AppError { + if error == AppError::WslCommandTimedOut { + AppError::GitTimeout { + timeout_secs: u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX), + } + } else { + error } +} - #[cfg(unix)] - #[test] - fn git_acquisition_rejects_an_unmanaged_destination_before_deleting_it() { - let temp = tempfile::tempdir().expect("temp"); - let destination = temp.path().join("existing/repo"); - fs::create_dir_all(&destination).expect("destination"); - fs::write(destination.join("keep"), b"keep").expect("marker"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("/missing/source") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .output() - .expect("acquisition script"); - - assert!(!output.status.success()); - assert!(destination.join("keep").is_file()); +fn protocol_error(message: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: message.to_string(), } +} - #[cfg(target_os = "linux")] - #[test] - fn failed_git_acquisition_removes_its_managed_temporary_root() { - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("/missing/source") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("30") - .output() - .expect("acquisition script"); +#[cfg(all(test, target_os = "windows"))] +#[allow( + clippy::disallowed_methods, + reason = "真实 WSL 2 门禁的 Drop guard 需要同步启动 wsl.exe 清理测试 fixture" +)] +mod windows_wsl2_tests { + use std::process::Stdio; - assert!(!output.status.success()); - assert!(!managed_root.exists()); - } + use crate::application::payload_session::{PayloadSessionStorage, PayloadStorageKey}; + use crate::core::mutation::CancellationSignal; + use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; + use crate::environment::wsl::operations::scan::{scan, ScanRequest}; + use crate::environment::wsl::WslRuntime; - #[cfg(target_os = "linux")] - #[test] - fn git_timeout_starts_when_the_clone_process_starts() { - let temp = tempfile::tempdir().expect("fake Git temp dir"); - let fake_git = temp.path().join("git"); - fs::write(&fake_git, "#!/bin/sh\nsleep 10\n").expect("fake Git"); - let mut permissions = fs::metadata(&fake_git) - .expect("fake Git metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_git, permissions).expect("make fake Git executable"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", + #[tokio::test] + #[ignore = "requires Windows with an Ubuntu WSL 2 distribution"] + async fn real_wsl2_worker_completes_git_scan_payload_and_release() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let fixture = format!( + "/tmp/skill-deck-worker-gate-{}", uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").unwrap() ); - let started = std::time::Instant::now(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("https://github.com/example/repo") - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("1") - .arg("preserve") - .arg("") - .env("PATH", path) - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(72)); - assert!(started.elapsed() < std::time::Duration::from_secs(4)); - assert!(!managed_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_probe_uses_a_stable_diagnostic_locale() { - let temp = tempfile::tempdir().expect("fake Git temp dir"); - let fake_git = temp.path().join("git"); - fs::write( - &fake_git, - "#!/bin/sh\n[ \"${LC_ALL-}\" = C ] || exit 73\n[ \"${GIT_ALLOW_PROTOCOL-}\" = https:http:ssh:git:file ] || exit 74\nprintf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\tHEAD\\n'\n", + run_fixture_command( + &distro, + r#"set -eu +root=$1 +mkdir -p "$root" +git init -b main "$root" +git -C "$root" config user.email test@example.com +git -C "$root" config user.name 'Skill Deck Test' +printf '%s\n' '---' 'name: worker-gate' 'description: Worker gate' '---' > "$root/SKILL.md" +git -C "$root" add SKILL.md +git -C "$root" commit -m fixture +"#, + &fixture, ) - .expect("fake Git"); - let mut permissions = fs::metadata(&fake_git) - .expect("fake Git metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_git, permissions).expect("make fake Git executable"); - let path = format!( - "{}:{}", - temp.path().display(), - std::env::var("PATH").expect("PATH") - ); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git-probe") - .arg("https://github.com/example/repo") - .arg("Ubuntu") - .arg("3") - .arg("preserve") - .arg("") - .env("PATH", path) - .output() - .expect("acquisition script"); - - assert!( - output.status.success(), - "git probe failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - output.stdout, - b"1\0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_reports_missing_git_before_creating_a_managed_root() { - let empty_path = tempfile::tempdir().expect("empty PATH"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg("https://github.com/example/repo.git") - .arg(&destination) - .arg("") - .arg("Ubuntu-24.04") - .arg("5") - .arg("preserve") - .arg("") - .env("PATH", empty_path.path()) - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(127)); - assert!(String::from_utf8_lossy(&output.stderr).contains("install Git")); - assert!(!managed_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn git_acquisition_blocks_ext_protocol_before_running_its_helper() { - let temp = tempfile::tempdir().expect("temp"); - let marker = temp.path().join("ext-helper-ran"); - let managed_root = std::path::PathBuf::from(format!( - "/tmp/skill-deck-discovery-test-{}", - uuid::Uuid::new_v4().simple() - )); - let destination = managed_root.join("repo"); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(super::WSL_SOURCE_ACQUISITION_SCRIPT) - .arg("--") - .arg("git") - .arg(format!("ext::touch {}", marker.display())) - .arg(&destination) - .arg("") - .arg("Ubuntu") - .arg("5") - .arg("preserve") - .arg("") - .output() - .expect("acquisition script"); - - assert_eq!(output.status.code(), Some(68)); - assert!(!marker.exists()); - assert!(!managed_root.exists()); - } - - #[tokio::test] - async fn cancelled_acquisition_does_not_start_wsl_command() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - cancellation.cancel(); - let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let ran_by_command = ran.clone(); + .await; + let _fixture_cleanup = FixtureCleanup { + distro: distro.clone(), + fixture: fixture.clone(), + }; - let error = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - move |_, _, _, _, _, _| async move { - ran_by_command.store(true, std::sync::atomic::Ordering::Release); - Ok(Vec::new()) + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).unwrap(); + let session = runtime.connect(&distro).await.unwrap(); + let source = super::acquire_wsl_source_native( + workspace.clone(), + &session, + super::WslAcquisitionSource::Git { + url: fixture.clone(), + git_ref: None, }, + std::time::Duration::from_secs(30), + None, + CancellationSignal::default(), ) .await - .expect_err("cancelled acquisition must fail"); - - assert!(error.to_string().contains("cancelled")); - assert!(!ran.load(std::sync::atomic::Ordering::Acquire)); - } - - #[tokio::test] - async fn cancellation_stops_waiting_for_running_wsl_command() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - let cancellation_request = cancellation.clone(); - let run = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - |_, _, _, _, _, cancellation| async move { - while !cancellation.is_cancelled() { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - Err(crate::error::AppError::MutationCancelled) + .unwrap(); + let inventory = scan( + &workspace, + &source, + ScanRequest { + roots: vec![source.native_root().to_string()], + stat_only_root_indexes: Default::default(), + recursive: true, + per_file_limit: 256 * 1024, + aggregate_limit: 1024 * 1024, }, + None, + ) + .await + .unwrap(); + assert!(inventory + .entries + .iter() + .any(|entry| entry.relative_path == "SKILL.md")); + + let storage = WslPayloadSessionStorage::for_source(workspace, &source); + let key = PayloadStorageKey::new("worker-gate", "SKILL.md"); + let acquired = storage + .acquire_from_source_path(&key, source.native_root(), None) + .await + .unwrap(); + assert_eq!( + storage.verify(&key).await.unwrap().unwrap(), + acquired.manifest ); - let cancel = async move { - tokio::time::sleep(std::time::Duration::from_millis(30)).await; - cancellation_request.cancel(); - }; - - let (result, ()) = tokio::join!(run, cancel); - - assert!(result - .expect_err("running acquisition must observe cancellation") - .to_string() - .contains("cancelled")); + let blob_id = acquired + .manifest + .entries + .iter() + .find_map(|entry| entry.blob_id.as_deref()) + .unwrap(); + assert!(!storage + .read_blob(&key, blob_id) + .await + .unwrap() + .unwrap() + .is_empty()); + storage.remove(&key).await.unwrap(); + drop(source); } - #[tokio::test] - async fn cancellation_waits_for_runner_cleanup_before_returning() { - let plan = git_operation(); - let cancellation = CancellationSignal::default(); - let cancellation_request = cancellation.clone(); - let cleanup_finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let cleanup_from_runner = cleanup_finished.clone(); - let run = run_wsl_acquisition_plan_with( - session(), - plan, - cancellation, - move |_, _, _, _, _, cancellation| async move { - while !cancellation.is_cancelled() { - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - cleanup_from_runner.store(true, std::sync::atomic::Ordering::Release); - Err(crate::error::AppError::MutationCancelled) - }, - ); - let cancel = async move { - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - cancellation_request.cancel(); - }; - - let (result, ()) = tokio::join!(run, cancel); - - assert_eq!(result, Err(crate::error::AppError::MutationCancelled)); - assert!(cleanup_finished.load(std::sync::atomic::Ordering::Acquire)); + async fn run_fixture_command(distro: &str, script: &str, fixture: &str) { + let status = crate::environment::wsl::wsl_command() + .args([ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + script, + "--", + fixture, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .await + .unwrap(); + assert!(status.success()); + } + + struct FixtureCleanup { + distro: String, + fixture: String, + } + + impl Drop for FixtureCleanup { + fn drop(&mut self) { + let _ = std::process::Command::new("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "/bin/rm", + "-rf", + "--", + &self.fixture, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } } } diff --git a/src-tauri/src/environment/wsl/scripts/acquire.sh b/src-tauri/src/environment/wsl/scripts/acquire.sh deleted file mode 100644 index 189c62fa..00000000 --- a/src-tauri/src/environment/wsl/scripts/acquire.sh +++ /dev/null @@ -1,523 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - source-revision) - repository_root=$1 - skill_path=$2 - case "$repository_root" in /*) ;; *) exit 61 ;; esac - case "$skill_path" in /*|..|../*|*/..|*/../*) exit 62 ;; esac - physical_root=$(cd -P -- "$repository_root" 2>/dev/null && pwd -P) || exit 63 - if [ -n "$skill_path" ]; then - revision_spec=HEAD:$skill_path - else - revision_spec='HEAD^{tree}' - fi - revision=$(git -C "$physical_root" rev-parse --verify "$revision_spec" 2>/dev/null) || exit 64 - case "${#revision}" in 40|64) ;; *) exit 65 ;; esac - case "$revision" in *[!0-9a-fA-F]*) exit 66 ;; esac - printf '1\0%s\0' "$revision" - ;; - fingerprint) - source_root=$1 - physical_root=$(cd -P -- "$source_root" 2>/dev/null && pwd -P) || exit 61 - [ -d "$physical_root" ] || exit 62 - - # Reject links that cannot be resolved inside the selected Skill. - # shellcheck disable=SC2016 - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type l -exec /bin/sh -c ' - root=$1 - shift - for link do - target=$(readlink -f -- "$link" 2>/dev/null) || exit 1 - case "$target" in "$root"/*) ;; *) exit 1 ;; esac - done - ' sh "$physical_root" {} + || exit 63 - - # shellcheck disable=SC2016 - fingerprint=$( - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - ! -name metadata.json -print0 \ - | LC_ALL=C sort -z \ - | xargs -0 -r /bin/sh -c ' - root=$1 - shift - for path do - relative=${path#"$root"/} - if [ -L "$path" ]; then - kind=link - target=$(readlink -- "$path") || exit 1 - elif [ -d "$path" ]; then - kind=directory - target= - elif [ -f "$path" ]; then - kind=file - target= - else - kind=other - target= - fi - size=$(stat -c %s -- "$path") || exit 1 - modified=$(stat -c %Y -- "$path") || exit 1 - mode=$(stat -c %a -- "$path") || exit 1 - printf "%s\0%s\0%s\0%s\0%s\0%s\0" \ - "$relative" "$kind" "$size" "$modified" "$mode" "$target" || exit 1 - done - ' sh "$physical_root" \ - | sha256sum - ) || exit 64 - fingerprint=${fingerprint%% *} - [ "${#fingerprint}" -eq 64 ] || exit 65 - case "$fingerprint" in *[!0-9a-f]*) exit 66 ;; esac - printf '1\0%s\0' "$fingerprint" - ;; - acquire) - - source_root=$1 - session_root=$2 - payload_root=$3 - session_id=$4 - physical_root=$(cd -P -- "$source_root" 2>/dev/null && pwd -P) || exit 61 - [ -d "$physical_root" ] || exit 62 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 63 - case "$payload_name" in payload-*) ;; *) exit 64 ;; esac - case "$payload_name" in */*) exit 65 ;; esac - umask 077 - if [ ! -e "$session_root" ] && [ ! -L "$session_root" ]; then - mkdir -- "$session_root" || exit 66 - marker_tmp=$session_root/.skill-deck-owner.$$ - trap 'rm -rf -- "${stage-}" "$marker_tmp"' EXIT HUP INT TERM - printf '1\n%s\n' "$session_id" > "$marker_tmp" || exit 67 - mv -- "$marker_tmp" "$session_root/.skill-deck-owner" || exit 68 - else - trap 'rm -rf -- "${stage-}"' EXIT HUP INT TERM - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 69 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 70 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 71 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 72 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 73 - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 74 - stage=$session_root/.stage-$payload_name-$$ - mkdir -- "$stage" || exit 75 - mkdir -- "$stage/blobs" || exit 76 - - # shellcheck disable=SC2016 - find "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type l -exec /bin/sh -c ' - root=$1 - stage=$2 - shift 2 - for link do - target=$(readlink -f -- "$link" 2>/dev/null) || { : > "$stage/.failed"; continue; } - case "$target" in "$root"/*) ;; *) : > "$stage/.failed" ;; esac - done - ' sh "$physical_root" "$stage" {} + || exit 77 - [ ! -e "$stage/.failed" ] || exit 78 - - # shellcheck disable=SC2016 - cli_hash=$( - find -L "$physical_root" -mindepth 1 \ - \( -name .git -o -name node_modules -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -type f ! -name metadata.json -print0 \ - | LC_ALL=C sort -z -f \ - | xargs -0 -r /bin/sh -c ' - root=$1 - shift - for path do - relative=${path#"$root"/} - printf %s "$relative" || exit 1 - cat -- "$path" || exit 1 - done - ' sh "$physical_root" \ - | sha256sum - ) || exit 77 - cli_hash=${cli_hash%% *} - [ "${#cli_hash}" -eq 64 ] || exit 78 - case "$cli_hash" in *[!0-9a-f]*) exit 79 ;; esac - printf '1\0H\0%s\0' "$cli_hash" - # shellcheck disable=SC2016 - find -L "$physical_root" -mindepth 1 \ - \( -name .git -o -name __pycache__ -o -name __pypackages__ \) -prune -o \ - -exec /bin/sh -c ' - root=$1 - stage=$2 - shift 2 - for path do - relative=${path#"$root"/} - if [ -d "$path" ]; then - printf "E\\0%s\\0%s\\0%s\\0%s\\0%s\\0" \ - directory "$relative" "" 0 0 - continue - fi - [ -f "$path" ] || continue - [ "${path##*/}" != metadata.json ] || continue - digest_line=$(sha256sum -- "$path" 2>/dev/null) || { - : > "$stage/.failed" - continue - } - blob_id=${digest_line%% *} - size=$(wc -c < "$path" 2>/dev/null) || { : > "$stage/.failed"; continue; } - mode=$(stat -c %a -- "$path" 2>/dev/null) || { : > "$stage/.failed"; continue; } - executable=$(( (0$mode & 0111) != 0 )) - blob=$stage/blobs/$blob_id - if [ ! -e "$blob" ]; then - cp -- "$path" "$blob" 2>/dev/null || { : > "$stage/.failed"; continue; } - chmod 600 -- "$blob" 2>/dev/null || { : > "$stage/.failed"; continue; } - fi - digest_line=$(sha256sum -- "$blob" 2>/dev/null) || { - : > "$stage/.failed" - continue - } - [ "${digest_line%% *}" = "$blob_id" ] || { - : > "$stage/.failed" - continue - } - printf "E\\0%s\\0%s\\0%s\\0%s\\0%s\\0" \ - file "$relative" "$blob_id" "$size" "$executable" - done - ' sh "$physical_root" "$stage" {} + || exit 80 - [ ! -e "$stage/.failed" ] || exit 81 - find "$stage/blobs" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ - | LC_ALL=C sort > "$stage/blob-list" || exit 82 - mv -- "$stage" "$payload_root" || exit 83 - stage= - trap - EXIT HUP INT TERM - - ;; - store-begin) - - session_root=$1 - payload_root=$2 - session_id=$3 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 61 - case "$payload_name" in payload-*) ;; *) exit 62 ;; esac - case "$payload_name" in */*) exit 63 ;; esac - stage=$payload_root.upload - umask 077 - if [ ! -e "$session_root" ] && [ ! -L "$session_root" ]; then - mkdir -- "$session_root" || exit 64 - marker_tmp=$session_root/.skill-deck-owner.$$ - trap 'rm -rf -- "$stage" "$marker_tmp"' EXIT HUP INT TERM - printf '1\n%s\n' "$session_id" > "$marker_tmp" || exit 65 - mv -- "$marker_tmp" "$session_root/.skill-deck-owner" || exit 66 - else - trap 'rm -rf -- "$stage"' EXIT HUP INT TERM - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 67 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 68 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 69 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 70 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 71 - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 72 - [ ! -e "$stage" ] && [ ! -L "$stage" ] || exit 73 - mkdir -- "$stage" || exit 74 - mkdir -- "$stage/blobs" || exit 75 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - store-blob) - - session_root=$1 - payload_root=$2 - session_id=$3 - blob_id=$4 - [ "${#blob_id}" -eq 64 ] || exit 61 - case "$blob_id" in *[!0-9a-f]*) exit 62 ;; esac - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 63 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 64 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 67 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 68 - case "$payload_name" in payload-*) ;; *) exit 69 ;; esac - case "$payload_name" in */*) exit 70 ;; esac - stage=$payload_root.upload - [ -d "$stage" ] && [ ! -L "$stage" ] || exit 71 - [ -d "$stage/blobs" ] && [ ! -L "$stage/blobs" ] || exit 72 - blob=$stage/blobs/$blob_id - [ ! -e "$blob" ] && [ ! -L "$blob" ] || exit 73 - tmp=$stage/.blob.$$ - trap 'rm -f -- "$tmp"' EXIT HUP INT TERM - cat > "$tmp" || exit 74 - [ "$(sha256sum -- "$tmp" | awk '{print $1}')" = "$blob_id" ] || exit 75 - chmod 600 -- "$tmp" || exit 76 - sync "$tmp" 2>/dev/null || true - mv -- "$tmp" "$blob" || exit 77 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - store-finalize) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ ! -e "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - stage=$payload_root.upload - [ -d "$stage" ] && [ ! -L "$stage" ] || exit 70 - [ -d "$stage/blobs" ] && [ ! -L "$stage/blobs" ] || exit 71 - find "$stage/blobs" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ - | LC_ALL=C sort > "$stage/blob-list" || exit 72 - IFS= read -r expected_count || exit 73 - case "$expected_count" in ''|*[!0-9]*) exit 74 ;; esac - exec 3< "$stage/blob-list" - index=0 - while [ "$index" -lt "$expected_count" ]; do - IFS= read -r expected_id || exit 75 - IFS= read -r actual_id <&3 || exit 76 - [ "$expected_id" = "$actual_id" ] || exit 77 - blob=$stage/blobs/$actual_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 78 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$actual_id" ] || exit 79 - index=$((index + 1)) - done - if IFS= read -r _ <&3; then exit 80; fi - manifest_tmp=$stage/.manifest.$$ - trap 'rm -f -- "$manifest_tmp"' EXIT HUP INT TERM - cat > "$manifest_tmp" || exit 81 - [ -s "$manifest_tmp" ] || exit 82 - sync "$manifest_tmp" 2>/dev/null || true - mv -- "$manifest_tmp" "$stage/manifest.json" || exit 83 - mv -- "$stage" "$payload_root" || exit 84 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - finalize) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ -d "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - [ -f "$payload_root/blob-list" ] && [ ! -L "$payload_root/blob-list" ] || exit 70 - [ -d "$payload_root/blobs" ] && [ ! -L "$payload_root/blobs" ] || exit 71 - [ ! -e "$payload_root/manifest.json" ] && [ ! -L "$payload_root/manifest.json" ] || exit 72 - IFS= read -r expected_count || exit 73 - case "$expected_count" in ''|*[!0-9]*) exit 74 ;; esac - exec 3< "$payload_root/blob-list" - index=0 - while [ "$index" -lt "$expected_count" ]; do - IFS= read -r expected_id || exit 75 - IFS= read -r actual_id <&3 || exit 76 - [ "$expected_id" = "$actual_id" ] || exit 77 - blob=$payload_root/blobs/$actual_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 78 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$actual_id" ] || exit 79 - index=$((index + 1)) - done - if IFS= read -r _ <&3; then exit 80; fi - manifest_tmp=$payload_root/.manifest.$$ - trap 'rm -f -- "$manifest_tmp"' EXIT HUP INT TERM - cat > "$manifest_tmp" || exit 81 - [ -s "$manifest_tmp" ] || exit 82 - sync "$manifest_tmp" 2>/dev/null || true - mv -- "$manifest_tmp" "$payload_root/manifest.json" || exit 83 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - verify) - - session_root=$1 - payload_root=$2 - session_id=$3 - validate_expected=0 - if [ "${4-}" = --expected ]; then - validate_expected=1 - fi - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ -d "$payload_root" ] && [ ! -L "$payload_root" ] || exit 69 - manifest=$payload_root/manifest.json - blob_list=$payload_root/blob-list - blobs=$payload_root/blobs - [ -f "$manifest" ] && [ ! -L "$manifest" ] || exit 70 - [ -f "$blob_list" ] && [ ! -L "$blob_list" ] || exit 71 - [ -d "$blobs" ] && [ ! -L "$blobs" ] || exit 72 - if [ "$validate_expected" -eq 1 ]; then - cmp -s - "$blob_list" || exit 78 - fi - actual_count=0 - while IFS= read -r blob_id || [ -n "$blob_id" ]; do - [ -n "$blob_id" ] || exit 73 - [ "${#blob_id}" -eq 64 ] || exit 74 - case "$blob_id" in *[!0-9a-f]*) exit 75 ;; esac - blob=$blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 76 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$blob_id" ] || exit 77 - actual_count=$((actual_count + 1)) - done < "$blob_list" - cat -- "$manifest" - - ;; - read-blob) - - session_root=$1 - payload_root=$2 - session_id=$3 - blob_id=$4 - [ "${#blob_id}" -eq 64 ] || exit 61 - case "$blob_id" in *[!0-9a-f]*) exit 62 ;; esac - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 63 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 64 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 67 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 68 - case "$payload_name" in payload-*) ;; *) exit 69 ;; esac - case "$payload_name" in */*) exit 70 ;; esac - [ -d "$payload_root/blobs" ] && [ ! -L "$payload_root" ] && [ ! -L "$payload_root/blobs" ] || exit 71 - blob=$payload_root/blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 72 - [ "$(sha256sum -- "$blob" | awk '{print $1}')" = "$blob_id" ] || exit 73 - cat -- "$blob" - - ;; - remove-payload) - - session_root=$1 - payload_root=$2 - session_id=$3 - [ ! -e "$session_root" ] && [ ! -L "$session_root" ] && exit 0 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 61 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 62 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 63 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 64 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 65 - payload_name=${payload_root#"$session_root"/} - [ "$payload_root" = "$session_root/$payload_name" ] || exit 66 - case "$payload_name" in payload-*) ;; *) exit 67 ;; esac - case "$payload_name" in */*) exit 68 ;; esac - [ ! -L "$payload_root" ] || exit 69 - [ ! -e "$payload_root" ] && exit 0 - [ -d "$payload_root" ] || exit 70 - rm -rf -- "$payload_root" - - ;; - remove-session) - - session_root=$1 - session_id=$2 - expected_root=/tmp/skill-deck-source-$session_id - [ "$session_root" = "$expected_root" ] || exit 61 - [ ! -e "$session_root" ] && [ ! -L "$session_root" ] && exit 0 - [ -d "$session_root" ] && [ ! -L "$session_root" ] || exit 62 - marker=$session_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 63 - [ "$(wc -l < "$marker")" -eq 2 ] || exit 64 - [ "$(sed -n '1p' "$marker")" = 1 ] || exit 65 - [ "$(sed -n '2p' "$marker")" = "$session_id" ] || exit 66 - rm -rf -- "$session_root" - - ;; - sweep-orphans) - - base=$1 - shift - case "$base" in /*) ;; *) exit 61 ;; esac - removed=0 - protected=0 - external=0 - blocked=0 - printf '1\0' - for root in "$base"/skill-deck-source-*; do - [ -e "$root" ] || [ -L "$root" ] || continue - candidate=${root##*/} - retain() { - code=$1 - blocked=1 - size=$(du -sb -- "$root" 2>/dev/null | awk '{print $1}') - case "$size" in ''|*[!0-9]*) - printf 'W\0sizeUnavailable\0%s\0-\0' "$candidate" - ;; - *) external=$((external + size)) ;; - esac - printf 'W\0%s\0%s\0-\0' "$code" "$candidate" - } - if [ ! -d "$root" ] || [ -L "$root" ]; then - retain boundaryRejected - continue - fi - marker=$root/.skill-deck-owner - if [ ! -f "$marker" ] || [ -L "$marker" ] || [ "$(wc -l < "$marker" 2>/dev/null)" -ne 2 ]; then - retain invalidMarker - continue - fi - version=$(sed -n '1p' "$marker") - session_id=$(sed -n '2p' "$marker") - if [ "$version" != 1 ]; then - retain futureMarkerVersion - continue - fi - if [ -z "$session_id" ] || [ "$root" != "$base/skill-deck-source-$session_id" ]; then - retain invalidMarker - continue - fi - is_protected=0 - for protected_id do - if [ "$protected_id" = "$session_id" ]; then - is_protected=1 - break - fi - done - if [ "$is_protected" -eq 1 ]; then - protected=$((protected + 1)) - continue - fi - if rm -rf -- "$root"; then - removed=$((removed + 1)) - else - retain deleteFailed - fi - done - printf 'S\0%s\0%s\0%s\0%s\0' "$removed" "$protected" "$external" "$blocked" - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/content-manifest.sh b/src-tauri/src/environment/wsl/scripts/content-manifest.sh deleted file mode 100644 index 32bcd8a3..00000000 --- a/src-tauri/src/environment/wsl/scripts/content-manifest.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - root=$1 - case "$root" in - /*) ;; - *) exit 64 ;; - esac - [ -d "$root" ] && [ ! -L "$root" ] && [ -r "$root" ] && [ -x "$root" ] || exit 66 - [ "$root" = / ] || root=${root%/} - - work_dir=$(mktemp -d "${TMPDIR:-/tmp}/skill-deck-manifest.XXXXXX") || exit 67 - trap 'rm -rf -- "$work_dir"' EXIT HUP INT TERM - records=$work_dir/records - counts=$work_dir/counts - : > "$records" || exit 67 - : > "$counts" || exit 67 - - if ! LC_ALL=C find "$root" -mindepth 1 -exec sh -c ' - root=$1 - records=$2 - counts=$3 - shift 3 - for path do - relative=${path#"$root"/} - [ "$relative" != "$path" ] || exit 68 - path_length=$(LC_ALL=C printf %s "$relative" | wc -c) || exit 69 - case "$path_length" in ""|*[!0-9]*) exit 69 ;; esac - - executable=0 - data= - data_file= - if [ -L "$path" ]; then - kind=l - data_file=$(mktemp "${records}.data.XXXXXX") || exit 70 - readlink -n -- "$path" > "$data_file" || exit 70 - elif [ -d "$path" ]; then - kind=d - elif [ -f "$path" ]; then - kind=f - digest_line=$(sha256sum -- "$path") || exit 71 - digest_line=${digest_line#\\} - data=${digest_line%% *} - mode=$(stat -Lc %a -- "$path") || exit 72 - case "$mode" in *[1357]*) executable=1 ;; esac - else - exit 73 - fi - if [ -n "$data_file" ]; then - data_length=$(wc -c < "$data_file") || exit 69 - else - data_length=$(LC_ALL=C printf %s "$data" | wc -c) || exit 69 - fi - case "$data_length" in ""|*[!0-9]*) exit 69 ;; esac - printf "R %s %s %s %s\n" "$kind" "$executable" "$path_length" "$data_length" >> "$records" || exit 74 - printf %s "$relative" >> "$records" || exit 74 - if [ -n "$data_file" ]; then - cat "$data_file" >> "$records" || exit 74 - rm -f -- "$data_file" || exit 74 - else - printf %s "$data" >> "$records" || exit 74 - fi - printf "1\n" >> "$counts" || exit 74 - done - ' sh "$root" "$records" "$counts" {} +; then - exit 75 - fi - - count=$(wc -l < "$counts") || exit 76 - case "$count" in ""|*[!0-9]*) exit 76 ;; esac - printf 'SDCM 1\n' - cat "$records" || exit 77 - printf 'E %s\n' "$count" - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/directory-count.sh b/src-tauri/src/environment/wsl/scripts/directory-count.sh deleted file mode 100644 index d3d31924..00000000 --- a/src-tauri/src/environment/wsl/scripts/directory-count.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - limit=$1 - shift - max=$((limit + 1)) - work_dir=$(mktemp -d "${TMPDIR:-/tmp}/skill-deck-count.XXXXXX") || exit 67 - trap 'rm -rf -- "$work_dir"' EXIT HUP INT TERM - printf '1\0' - index=0 - for path do - if [ ! -d "$path" ] || [ ! -r "$path" ] || [ ! -x "$path" ]; then - printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0 - continue - fi - entries="$work_dir/entries.$index" - index=$((index + 1)) - if ! LC_ALL=C find "$path" -mindepth 1 -maxdepth 1 -print0 > "$entries" 2>/dev/null; then - printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0 - continue - fi - count=$(LC_ALL=C tr -cd '\000' < "$entries" | head -c "$max" | wc -c) - case "$count" in - ""|*[!0-9]*) printf 'path\0%s\0none\0%s\0%s\0' "$path" 0 0; continue ;; - esac - truncated=0 - if [ "$count" -gt "$limit" ]; then - count=$limit - truncated=1 - fi - printf 'path\0%s\0count\0%s\0%s\0' "$path" "$count" "$truncated" - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/entry.sh b/src-tauri/src/environment/wsl/scripts/entry.sh deleted file mode 100644 index e77d2162..00000000 --- a/src-tauri/src/environment/wsl/scripts/entry.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - printf '1\0' - index=0 - for path do - kind=missing - device= - inode= - mode= - size= - mtime_seconds= - mtime_nanos= - link_target= - if [ -e "$path" ] || [ -L "$path" ]; then - if [ -L "$path" ]; then - kind=symlink - link_target=$(readlink -- "$path") || exit 61 - [ -e "$path" ] || kind=brokenLink - elif [ -d "$path" ]; then - kind=directory - elif [ -f "$path" ]; then - kind='file' - else - kind=other - fi - device=$(stat -c %d -- "$path") || exit 62 - inode=$(stat -c %i -- "$path") || exit 63 - mode=$(stat -c %f -- "$path") || exit 64 - size=$(stat -c %s -- "$path") || exit 65 - mtime_seconds=$(stat -c %Y -- "$path") || exit 66 - mtime_text=$(stat -c %y -- "$path") || exit 67 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - fi - printf 'S\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$index" "$kind" "$device" "$inode" "$mode" "$size" \ - "$mtime_seconds" "$mtime_nanos" "$link_target" - index=$((index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/eve.sh b/src-tauri/src/environment/wsl/scripts/eve.sh deleted file mode 100644 index 78914dc3..00000000 --- a/src-tauri/src/environment/wsl/scripts/eve.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - project=$1 - if [ ! -d "$project/agent" ] || [ ! -f "$project/package.json" ]; then - printf '0\0' - exit 0 - fi - printf '1\0' - cat -- "$project/package.json" - printf '\0' - for dir in "$project/agent/subagents"/*; do - [ -d "$dir" ] || continue - printf '%s\0' "${dir##*/}" - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/path-metadata.sh b/src-tauri/src/environment/wsl/scripts/path-metadata.sh deleted file mode 100644 index ee65d466..00000000 --- a/src-tauri/src/environment/wsl/scripts/path-metadata.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - inspect) - - missing_kind() { - probe=${1%/*} - [ -n "$probe" ] || probe=/ - while [ "$probe" != / ] && [ ! -e "$probe" ] && [ ! -L "$probe" ]; do - next=${probe%/*} - [ -n "$next" ] || next=/ - [ "$next" != "$probe" ] || break - probe=$next - done - if [ -d "$probe" ] && [ ! -x "$probe" ]; then - printf inaccessible - else - printf missing - fi - } - - printf '1\0' - while [ "$#" -ge 2 ]; do - path=$1 - inspect_content=$2 - shift 2 - if [ -L "$path" ]; then - if [ ! -e "$path" ]; then - kind=broken-link - elif [ -d "$path" ]; then - kind=symlink-directory - else - kind=symlink-other - fi - elif [ -d "$path" ]; then - kind=directory - elif [ -e "$path" ]; then - kind=other - else - kind=$(missing_kind "$path") - fi - printf 'path\0%s\0%s\0' "$path" "$kind" - if [ "$inspect_content" = 1 ] && [ -f "$path" ]; then - if payload=$(dd if="$path" bs=1048576 count=1 2>/dev/null); then - if [ -n "$payload" ]; then - printf 'eve\0%s\0' "$payload" - else - printf 'eve-empty\0-\0' - fi - else - printf 'eve-unreadable\0-\0' - fi - else - printf 'none\0-\0' - fi - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/path.sh b/src-tauri/src/environment/wsl/scripts/path.sh deleted file mode 100644 index 2ff26d43..00000000 --- a/src-tauri/src/environment/wsl/scripts/path.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - map-host) - mapped=$(wslpath -u -- "$1") || exit $?; case "$mapped" in /*) ;; *) exit 61 ;; esac; printf '1\0%s\0' "$mapped" - ;; - map-storage-host) - mapped=$(wslpath -w -- "$1") || exit $?; [ -n "$mapped" ] || exit 61; printf '1\0%s\0' "$mapped" - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/projection.sh b/src-tauri/src/environment/wsl/scripts/projection.sh deleted file mode 100644 index e61bfdd9..00000000 --- a/src-tauri/src/environment/wsl/scripts/projection.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - project-targets) - - printf '2\0' - index=0 - for destination do - case "$destination" in /*) ;; *) exit 61 ;; esac - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - relative=${destination##*/} - case "$relative" in ''|.|..) exit 62 ;; esac - while [ ! -e "$parent" ] && [ ! -L "$parent" ]; do - component=${parent##*/} - case "$component" in ''|.|..) exit 63 ;; esac - relative=$component/$relative - next=${parent%/*} - [ -n "$next" ] || next=/ - [ "$next" != "$parent" ] || exit 64 - parent=$next - done - [ -d "$parent" ] || exit 65 - resolved=$(realpath -e -- "$parent") || exit 66 - identity=$(stat -Lc '%d %i' -- "$parent") || exit 67 - storage_projection=$(wslpath -w -- "$resolved") || exit 68 - device=${identity%% *} - inode=${identity#* } - if [ "$resolved" = / ]; then - physical=/$relative - else - physical=$resolved/$relative - fi - printf 'P\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$index" "$device" "$inode" "$physical" "$relative" "$storage_projection" - index=$((index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/scan.sh b/src-tauri/src/environment/wsl/scripts/scan.sh deleted file mode 100644 index 39aa4c0f..00000000 --- a/src-tauri/src/environment/wsl/scripts/scan.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - scan) - - per_file=$1 - aggregate=$2 - stat_only=$3 - recursive=$4 - shift 4 - total=0 - root_index=0 - printf '2\0' - emit_entry() { - path=$1 - relative=$2 - kind=other - target= - size=0 - mode=0 - modified=0 - truncated=0 - error= - content_len=0 - if [ -L "$path" ]; then - kind=symlink - target=$(readlink -- "$path" 2>/dev/null) || error=readLinkFailed - if [ -f "$path" ]; then - size=$(wc -c < "$path" 2>/dev/null) || { size=0; error=readFailed; } - fi - elif [ -d "$path" ]; then - kind=directory - elif [ -f "$path" ]; then - kind='file' - size=$(wc -c < "$path" 2>/dev/null) || { size=0; error=readFailed; } - case "$relative" in - SKILL.md|*/SKILL.md|skills-lock.json) - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - ;; - esac - elif [ ! -e "$path" ]; then - kind=missing - fi - if [ "$kind" != missing ]; then - mode_text=$(stat -c %a -- "$path" 2>/dev/null) || { mode_text=0; error=statFailed; } - mode=$((0$mode_text)) - modified=$(stat -c %Y -- "$path" 2>/dev/null) || { modified=0; error=statFailed; } - fi - case "$relative" in - .claude-plugin/marketplace.json|.claude-plugin/plugin.json) - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - ;; - esac - if [ -z "$relative" ] && [ "${path##*/}" = skills-lock.json ]; then - remaining=$((aggregate - total)) - [ "$remaining" -lt 0 ] && remaining=0 - content_len=$size - [ "$content_len" -gt "$per_file" ] && content_len=$per_file - [ "$content_len" -gt "$remaining" ] && content_len=$remaining - [ "$content_len" -lt "$size" ] && truncated=1 - fi - printf 'E\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0%s\0' \ - "$root_index" "$relative" "$kind" "$target" "$size" "$mode" "$modified" "$truncated" "$error" "$content_len" - if [ "$content_len" -gt 0 ]; then - dd if="$path" bs=1 count="$content_len" status=none || exit 71 - total=$((total + content_len)) - fi - printf '\0' - } - is_stat_only() { - case ",$stat_only," in - *",$1,"*) return 0 ;; - *) return 1 ;; - esac - } - for root do - emit_entry "$root" '' - if ! is_stat_only "$root_index" && [ -d "$root" ] && [ ! -L "$root" ]; then - if [ "$recursive" = 1 ]; then - find "$root" -mindepth 1 -maxdepth 6 \ - \( -name .git -o -name node_modules -o -name dist -o -name build -o -name __pycache__ -o -name __pypackages__ \) -prune \ - -o \( \ - \( -type f -o -type l \) -a \( -iname SKILL.md -o -path "$root/.claude-plugin/marketplace.json" -o -path "$root/.claude-plugin/plugin.json" -o -path "$root/skills-lock.json" \) \ - \) -print | while IFS= read -r path; do - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - continue - fi - relative=${path#"$root"/} - emit_entry "$path" "$relative" - done - elif [ "$recursive" = 2 ]; then - for path in "$root"/* "$root"/.[!.]* "$root"/..?*; do - if [ ! -d "$path" ] || [ -L "$path" ]; then - continue - fi - find "$path" -mindepth 1 -maxdepth 1 \( -type f -o -type l \) -iname SKILL.md -print | while IFS= read -r skill_path; do - relative=${skill_path#"$root"/} - emit_entry "$skill_path" "$relative" - done - done - else - for path in "$root"/* "$root"/.[!.]* "$root"/..?*; do - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - continue - fi - relative=${path#"$root"/} - emit_entry "$path" "$relative" - if [ -d "$path" ]; then - skill_path=$path/SKILL.md - if [ -e "$skill_path" ] || [ -L "$skill_path" ]; then - emit_entry "$skill_path" "$relative/SKILL.md" - fi - fi - done - fi - fi - root_index=$((root_index + 1)) - done - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/skill-content.sh b/src-tauri/src/environment/wsl/scripts/skill-content.sh deleted file mode 100644 index bcb39f0e..00000000 --- a/src-tauri/src/environment/wsl/scripts/skill-content.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - read) - - dir=$1 - [ -d "$dir" ] || exit 44 - for candidate in "$dir"/*; do - [ -f "$candidate" ] || continue - base=${candidate##*/} - lower=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') - if [ "$lower" = 'skill.md' ]; then - cat -- "$candidate" - exit 0 - fi - done - exit 44 - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/source-acquisition.sh b/src-tauri/src/environment/wsl/scripts/source-acquisition.sh deleted file mode 100644 index e8a4b730..00000000 --- a/src-tauri/src/environment/wsl/scripts/source-acquisition.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/sh -LC_ALL=C -GIT_ALLOW_PROTOCOL=https:http:ssh:git:file -export LC_ALL GIT_ALLOW_PROTOCOL - -subcommand=$1 -shift -case "$subcommand" in - cleanup) - root=$1 - case "$root" in /tmp/skill-deck-discovery-*/repo) ;; *) exit 64 ;; esac - parent=${root%/repo} - case "$parent" in /tmp/skill-deck-discovery-*) ;; *) exit 64 ;; esac - rm -rf -- "$parent" - ;; - git) - url=$1 - dest=$2 - git_ref=$3 - distro=$4 - git_timeout_secs=$5 - proxy_mode=$6 - proxy_url=$7 - case "$dest" in /tmp/skill-deck-discovery-*/repo) ;; *) exit 65 ;; esac - parent=${dest%/repo} - case "$parent" in /tmp/skill-deck-discovery-*) ;; *) exit 65 ;; esac - [ ! -e "$parent" ] && [ ! -L "$parent" ] || exit 66 - command -v git >/dev/null 2>&1 || { - printf "Git is not available in WSL distro '%s'. Please install Git in that distro and try again.\n" "$distro" >&2 - exit 127 - } - command -v timeout >/dev/null 2>&1 || { - printf "GNU timeout is not available in WSL distro '%s'. Please install GNU coreutils in that distro and try again.\n" "$distro" >&2 - exit 127 - } - case "$git_timeout_secs" in ''|*[!0-9]*) exit 65 ;; esac - case "$proxy_mode" in inject|preserve) ;; *) exit 65 ;; esac - mkdir -- "$parent" || exit 67 - cleanup_parent=1 - trap '[ "$cleanup_parent" = 0 ] || rm -rf -- "$parent"' EXIT HUP INT TERM - if [ "$proxy_mode" = inject ] && [ -n "$git_ref" ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" clone --depth 1 --progress --branch "$git_ref" -- "$url" "$dest" - elif [ "$proxy_mode" = inject ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" clone --depth 1 --progress -- "$url" "$dest" - elif [ -n "$git_ref" ]; then - timeout --kill-after=2s "${git_timeout_secs}s" \ - git clone --depth 1 --progress --branch "$git_ref" -- "$url" "$dest" - else - timeout --kill-after=2s "${git_timeout_secs}s" \ - git clone --depth 1 --progress -- "$url" "$dest" - fi - clone_status=$? - case "$clone_status" in 0) ;; 124|137) exit 72 ;; *) exit 68 ;; esac - ref_revision=$(git -C "$dest" rev-parse --verify HEAD) || exit 69 - case "${#ref_revision}" in 40|64) ;; *) exit 69 ;; esac - case "$ref_revision" in *[!0-9a-f]*) exit 69 ;; esac - cleanup_parent=0 - trap - EXIT HUP INT TERM - printf '1\0%s\0' "$ref_revision" || exit 70 - ;; - git-probe) - url=$1 - distro=$2 - git_timeout_secs=$3 - proxy_mode=$4 - proxy_url=$5 - command -v git >/dev/null 2>&1 || { - printf "Git is not available in WSL distro '%s'. Please install Git in that distro and try again.\n" "$distro" >&2 - exit 127 - } - command -v timeout >/dev/null 2>&1 || exit 127 - case "$git_timeout_secs" in ''|*[!0-9]*) exit 65 ;; esac - case "$proxy_mode" in inject|preserve) ;; *) exit 65 ;; esac - if [ "$proxy_mode" = inject ]; then - ref_revision=$(timeout --kill-after=2s "${git_timeout_secs}s" \ - git -c "http.proxy=$proxy_url" ls-remote --exit-code -- "$url" HEAD) - else - ref_revision=$(timeout --kill-after=2s "${git_timeout_secs}s" \ - git ls-remote --exit-code -- "$url" HEAD) - fi - probe_status=$? - case "$probe_status" in 0) ;; 124|137) exit 72 ;; *) exit 68 ;; esac - ref_revision=${ref_revision%%[[:space:]]*} - case "${#ref_revision}" in 40|64) ;; *) exit 69 ;; esac - case "$ref_revision" in *[!0-9a-f]*) exit 69 ;; esac - printf '1\0%s\0' "$ref_revision" || exit 70 - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/runtime/copy_service.rs b/src-tauri/src/runtime/copy_service.rs index 194bba16..3df9d135 100644 --- a/src-tauri/src/runtime/copy_service.rs +++ b/src-tauri/src/runtime/copy_service.rs @@ -15,7 +15,6 @@ use crate::environment::planning::{ resolve_native_targets, ResolvedTargetFact, RuntimeTargetFactResolver, }; use crate::environment::types::{EnvironmentRef, ResourceLocator, StorageAccess}; -use crate::environment::wsl::operations::path::map_storage_path_to_host; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::runtime::plan_runner::{RuntimeExecutionDependencies, RuntimePlanExecutor}; @@ -82,13 +81,16 @@ impl RuntimeCopyProjectComparator { distro_name: &str, native_path: &str, ) -> Result { - let native_path = native_path.to_string(); - self.environments - .with_session_retry(distro_name, move |session| { - let native_path = native_path.clone(); - async move { map_storage_path_to_host(&session, &native_path, None).await } + let workspace = self.environments.workspace(distro_name)?; + workspace + .map_path_to_windows(native_path.to_string()) + .await? + .ok_or_else(|| AppError::StorageMappingUnsupported { + path: native_path.to_string(), + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, }) - .await } async fn storage_access(&self, environment: &EnvironmentRef, path: &str) -> StorageAccess { diff --git a/src-tauri/src/runtime/planning_facts.rs b/src-tauri/src/runtime/planning_facts.rs index 708cd4b5..86cbd232 100644 --- a/src-tauri/src/runtime/planning_facts.rs +++ b/src-tauri/src/runtime/planning_facts.rs @@ -17,6 +17,7 @@ use crate::core::agent_registry::AgentRegistrySnapshot; use crate::core::projects::{ProjectPathSemantics, ProjectsFile}; use crate::core::skill_lock; use crate::core::{get_config_path, paths::PATHS}; +use crate::environment::agent_environment::inspect_eve_project; use crate::environment::agent_environment::{AgentEnvironmentResolver, EnvironmentContext}; use crate::environment::context_resolver::ContextResolver; use crate::environment::context_resolver::ResolvedContext; @@ -30,7 +31,6 @@ use crate::environment::types::{ EnvironmentRef, EnvironmentStatus, ResourceLocator, SkillLocation, SkillLocationRef, }; use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::eve::inspect_eve_project; use crate::environment::wsl::{WslRuntime, WslSession}; use crate::error::AppError; use crate::models::InstallTargetInfo; @@ -251,12 +251,13 @@ async fn capture_wsl_base( session: WslSession, workspace: crate::environment::wsl::WslWorkspace, ) -> Result { - let io = WslAtomicDocumentIo::from_active_session(session.clone()); + let io = WslAtomicDocumentIo::from_active_session(session.clone(), workspace.clone()); let (resolved, project_schema_version) = resolve_wsl_context_from_io(&io, context, &session).await?; - let environment = wsl_environment_context(&resolved, session.clone(), workspace); + let environment = wsl_environment_context(&resolved, session.clone(), workspace.clone()); let targets = resolve_wsl_targets( &session, + &workspace, &[ resolved.skill_root.native_path.clone(), resolved.lock.native_path.clone(), @@ -266,7 +267,7 @@ async fn capture_wsl_base( .await?; let eve_targets = match resolved.project.as_ref() { Some(project) => { - let snapshot = inspect_eve_project(&session, &project.native_path).await?; + let snapshot = inspect_eve_project(&workspace, &project.native_path).await?; if snapshot.has_eve { crate::core::eve::eve_install_targets(&project.native_path, snapshot.subagents) } else { diff --git a/src-tauri/src/runtime/resource_service.rs b/src-tauri/src/runtime/resource_service.rs index 4416a779..5d793121 100644 --- a/src-tauri/src/runtime/resource_service.rs +++ b/src-tauri/src/runtime/resource_service.rs @@ -77,19 +77,13 @@ impl AuthorizedResourceReader for RuntimeResourceReader { } EnvironmentRef::Wsl { distro_name } => { let path = target.native_path; - self.environments - .with_session_retry(distro_name, move |session| { - let path = path.clone(); - async move { - let markdown = crate::environment::wsl::operations::skill_content::read_skill_markdown( - &session, - &path, - ) - .await?; - Ok(crate::core::skill::skill_content_from_markdown(&markdown)) - } - }) - .await + let workspace = self.environments.workspace(distro_name)?; + let markdown = + crate::environment::wsl::operations::skill_content::read_skill_markdown( + &workspace, &path, + ) + .await?; + Ok(crate::core::skill::skill_content_from_markdown(&markdown)) } } }) diff --git a/src-tauri/src/runtime/wsl_source.rs b/src-tauri/src/runtime/wsl_source.rs index 4a4e3823..3fb1bc34 100644 --- a/src-tauri/src/runtime/wsl_source.rs +++ b/src-tauri/src/runtime/wsl_source.rs @@ -353,7 +353,8 @@ async fn prepare_native_wsl_source( stat_only_root_indexes.insert(1); } let mut response = scan( - &session, + &workspace, + &native, ScanRequest { roots, stat_only_root_indexes, @@ -370,7 +371,8 @@ async fn prepare_native_wsl_source( ); if !plugin_search_dirs.is_empty() { let priority = scan_priority_directories( - &session, + &workspace, + &native, ScanRequest { roots: plugin_search_dirs .iter() @@ -398,16 +400,15 @@ async fn prepare_native_wsl_source( &policy.internal_skill_visibility, policy.full_depth, )?; - let storage = Arc::new(WslPayloadSessionStorage::new(workspace)); + let storage = Arc::new(WslPayloadSessionStorage::for_source(workspace, &native)); for skill in catalog.values_mut() { let source_root = format!( "{}/{}", native.native_root().trim_end_matches('/'), normalize_skill_folder_path(&skill.relative_path) ); - skill.source_metadata_fingerprint = storage - .source_metadata_fingerprint_in_active_session(&session, &source_root) - .await?; + skill.source_metadata_fingerprint = + storage.source_metadata_fingerprint(&source_root).await?; } let descriptor = DiscoverySourceDescriptor { source: source_identifier(&parsed, &requested_source), From 6a350b41ff6bb8fd760a0f465a28ef10d4774c9f Mon Sep 17 00:00:00 2001 From: Hccake Date: Thu, 3 Sep 2026 23:37:54 +0800 Subject: [PATCH 05/27] refactor(wsl): route mutations and persistence through the worker --- ...0-use-single-writer-library-persistence.md | 11 + ...centralize-scope-skill-version-election.md | 23 + ...ocal-documents-omit-runtime-environment.md | 11 + .../application/agent_selection_history.rs | 67 +- .../src/application/library_application.rs | 31 +- .../src/application/library_candidates.rs | 5 +- .../src/application/mutation/coordinator.rs | 454 ++-- src-tauri/src/core/lock_repository.rs | 56 +- src-tauri/src/core/lossless_lock.rs | 64 +- src-tauri/src/core/skill_lock.rs | 5 +- src-tauri/src/environment/lock_io.rs | 115 +- .../src/environment/native/materialize.rs | 161 +- src-tauri/src/environment/planning.rs | 25 +- src-tauri/src/environment/project_service.rs | 141 +- .../environment/wsl/operations/atomic_file.rs | 281 +-- .../environment/wsl/operations/document.rs | 336 +++ .../src/environment/wsl/operations/library.rs | 163 ++ .../wsl/operations/library_content.rs | 338 --- .../environment/wsl/operations/materialize.rs | 2067 +++++------------ .../src/environment/wsl/operations/mod.rs | 5 +- .../environment/wsl/operations/projects.rs | 196 +- .../environment/wsl/operations/recovery.rs | 747 ++---- .../environment/wsl/scripts/atomic-file.sh | 32 - .../wsl/scripts/library-content.sh | 189 -- .../environment/wsl/scripts/materialize.sh | 749 ------ .../src/environment/wsl/scripts/projects.sh | 12 - .../src/environment/wsl/scripts/recovery.sh | 135 -- src-tauri/src/runtime/plan_runner.rs | 79 +- src-tauri/src/runtime/recovery.rs | 22 +- src-tauri/src/runtime/skill_libraries.rs | 464 ++-- src-tauri/src/storage/lock_plan.rs | 181 +- src-tauri/src/test_support/native_workflow.rs | 28 +- 32 files changed, 2523 insertions(+), 4670 deletions(-) create mode 100644 docs/adr/0010-use-single-writer-library-persistence.md create mode 100644 docs/adr/0011-centralize-scope-skill-version-election.md create mode 100644 docs/adr/0012-environment-local-documents-omit-runtime-environment.md create mode 100644 src-tauri/src/environment/wsl/operations/document.rs create mode 100644 src-tauri/src/environment/wsl/operations/library.rs delete mode 100644 src-tauri/src/environment/wsl/operations/library_content.rs delete mode 100644 src-tauri/src/environment/wsl/scripts/atomic-file.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/library-content.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/materialize.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/projects.sh delete mode 100644 src-tauri/src/environment/wsl/scripts/recovery.sh diff --git a/docs/adr/0010-use-single-writer-library-persistence.md b/docs/adr/0010-use-single-writer-library-persistence.md new file mode 100644 index 00000000..fb13825a --- /dev/null +++ b/docs/adr/0010-use-single-writer-library-persistence.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# Skill Library 采用单写者持久化模型 + +Skill Library catalog 和成员目录只由当前 Skill Deck 进程写入。Tauri 单实例机制限制正常运行时的应用实例数量,`RuntimeAdmissionCoordinator` 统一控制持久化写入许可,Runtime Library Adapter 再按 Environment 串行化 catalog、成员内容和内部恢复 I/O。其他进程、命令行工具和用户直接修改 catalog 不属于受支持的写入方式。 + +这项取舍删除了 Native 操作系统文件锁和 WSL `flock` 守卫协议,同时保留成员条件提交、catalog 原子写入、目录备份和崩溃恢复。全局和项目 Skill 的兼容 lock 仍可能被 `skills` CLI 或用户工具修改,因此继续在提交前重新读取并检查修订。 + +正式支持多个 Skill Deck 实例、增加独立后台写入进程、允许其他产品写入 Library,或者让多个设备共享同一 Library 数据目录时,必须重新讨论跨进程协调协议。当前行为由[系统架构](../architecture.md#应用内部结构)、[执行与恢复](../execution-and-recovery.md#原子写入与-lock-提交)和[测试与验证规范](../testing.md#http时间与外部进程)负责说明。 diff --git a/docs/adr/0011-centralize-scope-skill-version-election.md b/docs/adr/0011-centralize-scope-skill-version-election.md new file mode 100644 index 00000000..6ea759a9 --- /dev/null +++ b/docs/adr/0011-centralize-scope-skill-version-election.md @@ -0,0 +1,23 @@ +--- +status: accepted +--- + +# Scope 内 Skill 版本选举采用统一规划 Module + +一个 Scope 同时包含通用 Skill 目录和多个 Agent 专用 Skill 目录。同名 Skill 可能具有直接安装版本,也可能来自多个按顺序应用的库。通用目录、各 Agent 专用目录以及多个 Agent 共用的物理目录需要分别确定当前使用的版本。 + +Skill Deck 先通过统一的 Agent 目录表解析通用 Skill 目录和 Agent 安装选项目录,再为具体 Skill 追加安全目录名。库候选 Source 独立读取库应用记录、catalog 和真实成员定位信息,并把预览证据与纯候选集合分开。安装、复制、移除、管理 Agent 和管理库应用通过 `application/scope_skill_planning` 规划 Scope 内同名 Skill;低层物理目录选举只作为该 Module 的私有实现。 + +选举 Module 按 `PhysicalTargetKey` 合并指向同一物理目录的位置。任一位置需要直接版本时,直接版本胜出;否则只选择第一个适用的有序库候选。后续候选不会参与胜出判断,只用于识别当前链接是否来自较低优先级或先前应用的库。当前版本中,同一 Scope 应用的所有库共用一组选定 Agent;Agent ID 只说明目录读取者和错误归属,不参与版本胜出判断。 + +通用 Skill 目录优先使用 Scope 中的直接安装版本,没有直接版本时按库顺序使用第一个同名版本。Agent 专用 Skill 目录只在直接 Skill 关联该物理目录时优先使用直接版本;否则使用关联到该目录且顺序最靠前的库版本。两类版本都不适用时,规划结果不保留该 Skill。通用目录存在直接版本,不会改变其他 Agent 专用目录的选举结果。 + +统一规划 Module 负责把直接安装 Skill 的 Agent 关联变化和 Skill 库应用关系转换成物理目录计划。链接、复制和转换内容是直接安装版本的写入方式,不改变其选举优先级。调用方继续负责内容取得、lock 或库应用记录、预览凭据、执行和未完成操作。所有相关写入流程都使用非可选的库候选 Source;没有可用库版本时,Source 返回带证据的空候选快照,调用方仍然经过统一选举。每个计划目录都进入执行期复核;无需写入的目录使用只读 `Keep`,旧预览不能授权已经变化的目录写入或应用状态提交。 + +当前版本没有为历史 Agent 安装项新增独立的所有权记录。规划 Module 能够明确识别已知库链接;为了兼容旧版 Skill Deck 和其他兼容工具已经创建的安装项,有效目录、有效符号链接或 junction 作为该物理目录中的直接版本保留。失效链接可以在存在目标版本时替换;文件等不能作为 Skill 目录处理的条目保持不变,并返回冲突。这项兼容规则不能证明有效条目的创建者,只保证当前操作不会把它误改为库版本。 + +这项决策替代了安装、复制、移除、管理 Agent 和库应用各自解释优先级的做法。分散处理会让通用目录中的直接版本错误影响其他 Agent 目录,也会在取消直接关联后遗漏库版本恢复。调用方向选举 Module 提交完整的有序候选,由选举 Module 统一取第一项;这让同一套目录识别和版本选择同时服务写入计划、管理 Agent 状态和库管理预览。 + +正式支持给正在应用的库增删成员并向多个 Scope 传播变化、需要精确区分 Skill Deck 与其他工具创建的 Agent 安装项,或者引入新的 Agent 内容转换与目录形态时,必须重新讨论版本选举输入、所有权证据和跨 Scope 的执行与恢复协议。当前版本继续禁止修改正在应用的库成员。 + +当前行为由 [领域词汇](../../CONTEXT.md#scope-与-skill-目录)、[产品行为与交互](../product.md#组织和应用-skill-库)、[Agent 模型](../agent-model.md#skill-库的-agent-选择)、[Skill 生命周期](../skill-lifecycle.md#安装)、[系统架构](../architecture.md#应用内部结构)、[执行与恢复](../execution-and-recovery.md#skill-变更如何执行) 和 [测试与验证规范](../testing.md#后端测试) 负责说明。 diff --git a/docs/adr/0012-environment-local-documents-omit-runtime-environment.md b/docs/adr/0012-environment-local-documents-omit-runtime-environment.md new file mode 100644 index 00000000..2f74baaf --- /dev/null +++ b/docs/adr/0012-environment-local-documents-omit-runtime-environment.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# Environment 本地文档不保存运行时 Environment 身份 + +`EnvironmentRef` 表示当前 Host 如何路由 Native 或 WSL 操作,同一 Linux 用户空间由 Linux Native 和 Windows WSL 访问时会得到不同的运行时表示。Environment 本地文档的归属已经由 Store、Adapter 和存储路径确定,因此只保存业务状态和 Environment 内部键;Repository Interface 显式接收运行时 Context,并在加载后绑定当前 Environment。Host 本地配置需要恢复用户选择或跨 Environment 路由时仍可保存 `EnvironmentRef`。 + +库应用记录在首次发布前按该规则删除 `target`,旧开发记录中的多余字段由当前 reader 忽略。Recovery marker 仍包含运行时 Environment 和 `ResourceLocator`,在迭代 5 迁移 Recovery 时按同一规则拆分磁盘状态与运行时索引,不在本次兼容修复中提前改写恢复协议。 + +同一 Environment 本地存储仍采用单写者模型。Linux Native Skill Deck 与 Windows Skill Deck 可以顺序访问同一个 WSL 用户空间,但不能同时写入;正式支持多个跨 Host 写入进程时需要重新讨论 ADR-0010 的协调协议。 diff --git a/src-tauri/src/application/agent_selection_history.rs b/src-tauri/src/application/agent_selection_history.rs index ced6d94d..95e177da 100644 --- a/src-tauri/src/application/agent_selection_history.rs +++ b/src-tauri/src/application/agent_selection_history.rs @@ -25,12 +25,18 @@ pub async fn get_last_selected_agents( } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); + let workspace = wsl.workspace(&distro_name)?; wsl.with_session_retry(&distro_name, move |session| { let context = context.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; read_last_selected_agents_with_io( - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, &global_lock_target(resolved.lock), ) .await @@ -63,13 +69,19 @@ pub async fn set_last_selected_agents( EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let selected_agent_ids = selected_agent_ids.to_vec(); - wsl.with_session_retry(&distro_name, move |session| { + let workspace = wsl.workspace(&distro_name)?; + wsl.with_session(&distro_name, move |session| { let context = context.clone(); let selected_agent_ids = selected_agent_ids.clone(); + let workspace = workspace.clone(); async move { - let resolved = ContextResolver::resolve_wsl(context, &session).await?; + let resolved = + ContextResolver::resolve_wsl(context, &session, &workspace).await?; write_last_selected_agents_with_io( - EnvironmentLockIo::ActiveWsl(session), + EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }, global_lock_target(resolved.lock), &selected_agent_ids, ) @@ -144,7 +156,7 @@ mod tests { "/tmp/skill-deck-agent-history-test-{}", uuid::Uuid::new_v4() ); - let wsl = WslRuntime::new_with_support(true, true); + let wsl = WslRuntime::for_wsl_test(); let outcome = wsl_history_round_trip(&wsl, &distro_name, &root).await; let cleanup = cleanup_wsl_history_test_root(&distro_name, &root).await; @@ -168,22 +180,25 @@ mod tests { distro_name: &str, root: &str, ) -> Result<(Option>, serde_json::Value), AppError> { - let mut session = wsl.connect(distro_name).await?; - session.home = root.to_string(); - session.xdg_state_home = None; - session.config_home = format!("{root}/.config"); - wsl.insert(session.clone()); + let session = wsl.connect(distro_name).await?; let environment = EnvironmentRef::Wsl { distro_name: distro_name.to_string(), }; - let context = SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, + let target = LockTarget { + primary: ResourceLocator { + environment: environment.clone(), + native_path: format!("{root}/skills-lock.json"), + }, + legacy: None, + schema: LockSchema::Global, + }; + let workspace = wsl.workspace(distro_name)?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session.clone()), + workspace: workspace.clone(), }; - let resolved = ContextResolver::resolve_wsl(context, &session).await?; - let io = EnvironmentLockIo::ActiveWsl(session); io.write_atomic( - &resolved.lock, + &target.primary, serde_json::to_vec(&serde_json::json!({ "version": 3, "skills": {}, @@ -194,9 +209,21 @@ mod tests { ) .await?; - let initial = get_last_selected_agents(&environment, wsl).await?; - set_last_selected_agents(&environment, wsl, &["claude-code".to_string()]).await?; - let value = serde_json::from_slice(&io.read(&resolved.lock).await?)?; + let initial = read_last_selected_agents_with_io(io, &target).await?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session.clone()), + workspace: workspace.clone(), + }; + write_last_selected_agents_with_io(io, target, &["claude-code".to_string()]).await?; + let io = EnvironmentLockIo::ActiveWsl { + session: Box::new(session), + workspace, + }; + let locator = ResourceLocator { + environment, + native_path: format!("{root}/skills-lock.json"), + }; + let value = serde_json::from_slice(&io.read(&locator).await?)?; Ok((initial, value)) } diff --git a/src-tauri/src/application/library_application.rs b/src-tauri/src/application/library_application.rs index 9eda9cda..0d36bd5c 100644 --- a/src-tauri/src/application/library_application.rs +++ b/src-tauri/src/application/library_application.rs @@ -68,16 +68,14 @@ pub struct PendingLibraryApplication { #[serde(rename_all = "camelCase")] pub struct LibraryApplicationRecord { pub schema_version: u32, - pub target: SkillLocationRef, pub current: LibraryApplicationState, pub pending_operation: Option, } impl LibraryApplicationRecord { - pub fn empty(target: SkillLocationRef) -> Self { + pub fn empty() -> Self { Self { schema_version: LIBRARY_APPLICATION_SCHEMA_VERSION, - target, current: LibraryApplicationState::default(), pending_operation: None, } @@ -267,6 +265,7 @@ pub trait LibraryApplicationRepository: Send + Sync { fn save_application<'a>( &'a self, + context: &'a SkillLocationRef, record: &'a LibraryApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; @@ -429,7 +428,9 @@ where target_application: built.preview.target.clone(), preview_fingerprint: built.preview.token.generation.clone(), }); - self.repository.save_application(&pending_record).await?; + self.repository + .save_application(&built.context, &pending_record) + .await?; self.execute(built, pending_record, cancellation).await } @@ -482,7 +483,7 @@ where pending_record: LibraryApplicationRecord, cancellation: CancellationSignal, ) -> Result { - let context = built.record.target.clone(); + let context = built.context.clone(); let expected_unit_count = built.plan.units.len(); let units = self.executor.execute(built.plan, cancellation).await; let completed = library_execution_completed(expected_unit_count, &units); @@ -495,7 +496,9 @@ where } else { pending_record }; - self.repository.save_application(&final_record).await?; + self.repository + .save_application(&context, &final_record) + .await?; let catalog = self.repository.load_catalog(&context).await?; Ok(LibraryApplicationResponse { application: summary(&final_record, &catalog)?, @@ -770,6 +773,7 @@ where units: if include_plan { units } else { Vec::new() }, }); Ok(BuiltLibraryApplication { + context: draft.context.clone(), preview: LibraryApplicationPreview { token, current: record.current.clone(), @@ -1076,6 +1080,7 @@ fn ensure_library_link_targets_supported<'a>( } struct BuiltLibraryApplication { + context: SkillLocationRef, preview: LibraryApplicationPreview, record: LibraryApplicationRecord, plan: crate::application::mutation::plan::MutationPlan, @@ -1237,6 +1242,7 @@ mod tests { fn save_application<'a>( &'a self, + _context: &'a SkillLocationRef, record: &'a LibraryApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { @@ -1508,7 +1514,7 @@ mod tests { ordered_library_ids: current_library_ids, selected_agent_ids: current_agent_ids.into_iter().collect(), }, - ..LibraryApplicationRecord::empty(context.clone()) + ..LibraryApplicationRecord::empty() }), catalog: Mutex::new(LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -1561,14 +1567,9 @@ mod tests { current: &[&str], pending: Option<(&[&str], &[&str])>, ) -> LibraryApplicationRecord { - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: crate::environment::types::SkillLocation::Global, - }; let ids = |values: &[&str]| values.iter().map(|id| LibraryId::parse(*id)).collect(); LibraryApplicationRecord { schema_version: LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context, current: LibraryApplicationState { ordered_library_ids: ids(current), selected_agent_ids: Vec::new(), @@ -1833,11 +1834,7 @@ mod tests { #[test] fn library_application_preview_evidence_changes_with_the_catalog() { - let context = SkillLocationRef { - environment: EnvironmentRef::Native, - scope: SkillLocation::Global, - }; - let record = LibraryApplicationRecord::empty(context); + let record = LibraryApplicationRecord::empty(); let target = LibraryApplicationState::default(); let catalog = LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, diff --git a/src-tauri/src/application/library_candidates.rs b/src-tauri/src/application/library_candidates.rs index 97d3514d..e514dbfd 100644 --- a/src-tauri/src/application/library_candidates.rs +++ b/src-tauri/src/application/library_candidates.rs @@ -671,6 +671,7 @@ mod tests { fn save_application<'a>( &'a self, + _context: &'a SkillLocationRef, _record: &'a LibraryApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async { Ok(()) }) @@ -818,7 +819,6 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![first_id.clone(), second_id.clone()], selected_agent_ids: vec![agent("cursor"), agent("codex")], @@ -895,7 +895,6 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), @@ -963,7 +962,6 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: LibraryApplicationState { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), @@ -1009,7 +1007,6 @@ mod tests { record: LibraryApplicationRecord { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, - target: context.clone(), current: current.clone(), pending_operation: Some(PendingLibraryApplication { operation_id: "operation-pending".to_string(), diff --git a/src-tauri/src/application/mutation/coordinator.rs b/src-tauri/src/application/mutation/coordinator.rs index 19013d01..d3e3820c 100644 --- a/src-tauri/src/application/mutation/coordinator.rs +++ b/src-tauri/src/application/mutation/coordinator.rs @@ -4,8 +4,10 @@ use std::pin::Pin; use std::sync::Arc; use crate::application::mutation::plan::{ExecutionUnit, MutationPlan, RuntimeRevisions}; +#[cfg(test)] +use crate::application::mutation::result::MutationWarningCode; use crate::application::mutation::result::{ - ErrorReport, MutationUnitResult, MutationUnitStatus, MutationWarning, MutationWarningCode, + ErrorReport, MutationUnitResult, MutationUnitStatus, MutationWarning, }; use crate::application::payload_session::PinnedPayloadLease; use crate::core::mutation::CancellationSignal; @@ -28,6 +30,7 @@ pub type MutationUnitObserver<'a> = Arc( &'a self, unit: &'a ExecutionUnit, @@ -59,6 +62,99 @@ pub trait PreparedLockCommitter: Send + Sync { ) -> BoxFuture<'a, Result>; } +#[derive(Debug, Clone)] +pub struct UnitTransactionReceipt { + pub lock: Option, + pub warnings: Vec, +} + +pub trait PreparedUnitExecutor: Send + Sync { + type Prepared: Send; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result>; + + fn execute<'a>( + &'a self, + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result>; +} + +#[cfg(test)] +pub struct PhasedUnitExecutor { + entries: E, + locks: L, +} + +#[cfg(test)] +impl PhasedUnitExecutor { + pub fn new(entries: E, locks: L) -> Self { + Self { entries, locks } + } +} + +#[cfg(test)] +impl PreparedUnitExecutor for PhasedUnitExecutor +where + E: PreparedEntryExecutor, + L: PreparedLockCommitter, +{ + type Prepared = E::Staged; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + self.entries.stage(unit, payloads, cancellation) + } + + fn execute<'a>( + &'a self, + mut prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let result = async { + self.entries.recheck_entries(&prepared).await?; + self.entries.swap(&mut prepared).await?; + self.entries.verify(&prepared).await?; + match lock { + Some(lock) => self.locks.commit(lock).await.map(Some), + None => Ok(None), + } + } + .await; + match result { + Ok(lock) => match self.entries.cleanup(prepared).await { + Ok(warnings) => Ok(UnitTransactionReceipt { lock, warnings }), + Err(error) => Ok(UnitTransactionReceipt { + lock, + warnings: vec![MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some(error.to_string().chars().take(4096).collect()), + }], + }), + }, + Err(primary) => { + let restore = self.entries.restore(&mut prepared).await; + let _ = self.entries.cleanup(prepared).await; + Err(restore.err().unwrap_or(primary)) + } + } + }) + } +} + pub trait RuntimeRevisionSource: Send + Sync { fn current<'a>( &'a self, @@ -101,24 +197,18 @@ pub struct RuntimeRevisionSnapshot { pub authority: RuntimeAuthorityRevisions, } -pub struct MutationCoordinator { - entries: E, - locks: L, +pub struct MutationCoordinator { + units: E, revisions: R, } -impl MutationCoordinator +impl MutationCoordinator where - E: PreparedEntryExecutor, - L: PreparedLockCommitter, + E: PreparedUnitExecutor, R: RuntimeRevisionSource, { - pub fn new(entries: E, locks: L, revisions: R) -> Self { - Self { - entries, - locks, - revisions, - } + pub fn new(units: E, revisions: R) -> Self { + Self { units, revisions } } #[cfg(test)] @@ -147,11 +237,11 @@ where } let runtime = self.revisions.snapshot(&unit.target).await?; validate_runtime_revisions(&runtime.revisions, &unit.expected_revisions)?; - let staged = self - .entries - .stage(&unit, &plan.payloads, cancellation.clone()) + let prepared = self + .units + .prepare(&unit, &plan.payloads, cancellation.clone()) .await?; - Ok((staged, runtime.authority)) + Ok((prepared, runtime.authority)) } .await; staged_units.insert(index, preflight); @@ -164,8 +254,7 @@ where .remove(&index) .expect("every unit has a preflight result") { - Ok((staged, _)) => { - let _ = self.entries.cleanup(staged).await; + Ok((_prepared, _)) => { results.push(failed_result(unit, AppError::MutationCancelled, false)); } Err(error) => { @@ -188,8 +277,8 @@ where let staged = staged_units .remove(&index) .expect("every unit has a preflight result"); - let (mut staged, expected_authority) = match staged { - Ok(staged) => staged, + let (prepared, expected_authority) = match staged { + Ok(prepared) => prepared, Err(error) => { results.push(failed_result(&unit, error_for_preflight(&error), false)); continue; @@ -200,36 +289,26 @@ where .iter() .any(|target| blocked_targets.contains(&target.key)) { - let _ = self.entries.cleanup(staged).await; results.push(not_run(&unit, AppError::StaleTarget)); continue; } if cancellation.is_cancelled() { - let _ = self.entries.cleanup(staged).await; results.push(failed_result(&unit, AppError::MutationCancelled, false)); continue; } - let phase_result = async { + let transaction = async { self.recheck_runtime_authority(&unit, &expected_authority) .await?; - self.entries.recheck_entries(&staged).await?; - self.entries.swap(&mut staged).await?; - self.entries.verify(&staged).await?; - let receipt = match &unit.lock_mutation { - Some(mutation) => Some(self.locks.commit(mutation).await?), - None => None, - }; - Ok::<_, AppError>(receipt) + self.units + .execute(prepared, unit.lock_mutation.as_ref(), cancellation.clone()) + .await } .await; - let receipt = match phase_result { - Ok(receipt) => receipt, - Err(primary) => { - let restore = self.entries.restore(&mut staged).await; - let _ = self.entries.cleanup(staged).await; - let error = restore.err().unwrap_or(primary); + let transaction = match transaction { + Ok(transaction) => transaction, + Err(error) => { if matches!(error, AppError::RecoveryRequired { .. }) { blocked_targets.extend( unit.expected_targets @@ -242,24 +321,11 @@ where } }; - if let Some(receipt) = &receipt { + if let Some(receipt) = &transaction.lock { advance_future_lock_expectations(&mut plan.units[index + 1..], &unit, receipt); } let lock_committed = unit.lock_mutation.is_some(); - match self.entries.cleanup(staged).await { - Ok(warnings) => results.push(success_result(&unit, lock_committed, warnings)), - Err(error) => { - results.push(success_result( - &unit, - lock_committed, - vec![MutationWarning { - code: MutationWarningCode::BackupCleanupFailed, - parameters: BTreeMap::new(), - technical_details: Some(error.to_string().chars().take(4096).collect()), - }], - )); - } - } + results.push(success_result(&unit, lock_committed, transaction.warnings)); } results } @@ -282,6 +348,18 @@ where } } +#[cfg(test)] +impl MutationCoordinator, R> +where + E: PreparedEntryExecutor, + L: PreparedLockCommitter, + R: RuntimeRevisionSource, +{ + pub fn from_phases(entries: E, locks: L, revisions: R) -> Self { + Self::new(PhasedUnitExecutor::new(entries, locks), revisions) + } +} + fn validate_runtime_revisions( actual: &RuntimeRevisions, expected: &RuntimeRevisions, @@ -420,177 +498,125 @@ mod tests { #[derive(Clone, Copy, PartialEq, Eq)] enum Failure { None, - StageSecond, + PrepareSecond, CancelAfterSecond, - Recheck, - Lock, + Execute, Cleanup, - LockAndRestoreRequired, + RecoveryRequired, } - struct FakeEntryExecutor { + struct FakeUnitExecutor { log: Arc>>, failure: Failure, } - struct FakeStaged { + struct FakePrepared { unit_id: String, } - impl PreparedEntryExecutor for FakeEntryExecutor { - type Staged = FakeStaged; + impl PreparedUnitExecutor for FakeUnitExecutor { + type Prepared = FakePrepared; - fn stage<'a>( + fn prepare<'a>( &'a self, unit: &'a ExecutionUnit, _payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { - if self.failure == Failure::StageSecond && unit.id == "second" { + if self.failure == Failure::PrepareSecond && unit.id == "second" { return Err(AppError::ExecutionFailed { - message: "second target cannot be staged".to_string(), + message: "second target cannot be prepared".to_string(), }); } - self.log.lock().unwrap().push(format!("stage:{}", unit.id)); - self.log.lock().unwrap().push(format!("marker:{}", unit.id)); + self.log + .lock() + .unwrap() + .push(format!("prepare:{}", unit.id)); if self.failure == Failure::CancelAfterSecond && unit.id == "second" { cancellation.cancel(); } - Ok(FakeStaged { + Ok(FakePrepared { unit_id: unit.id.clone(), }) }) } - fn recheck_entries<'a>( + fn execute<'a>( &'a self, - staged: &'a Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("recheck:{}", staged.unit_id)); - if self.failure == Failure::Recheck { - return Err(AppError::StaleTarget); - } - Ok(()) - }) - } - - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { self.log .lock() .unwrap() - .push(format!("swap:{}", staged.unit_id)); - Ok(()) - }) - } - - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("verify:{}", staged.unit_id)); - Ok(()) - }) - } - - fn restore<'a>( - &'a self, - staged: &'a mut Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("restore:{}", staged.unit_id)); - if self.failure == Failure::LockAndRestoreRequired { + .push(format!("execute:{}", prepared.unit_id)); + if self.failure == Failure::RecoveryRequired && prepared.unit_id == "first" { return Err(AppError::RecoveryRequired { recovery_resource_id: RecoveryResourceId::parse(format!( "recovery-{}", - staged.unit_id + prepared.unit_id )) .unwrap(), - message: "restore failed".to_string(), + message: "transaction result is unknown".to_string(), }); } - Ok(()) - }) - } - - fn cleanup<'a>( - &'a self, - staged: Self::Staged, - ) -> BoxFuture<'a, Result, AppError>> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("cleanup:{}", staged.unit_id)); - if self.failure == Failure::Cleanup { - Err(AppError::ExecutionFailed { - message: "backup remains".to_string(), - }) - } else { - Ok(Vec::new()) + if self.failure == Failure::Execute { + return Err(AppError::StaleTarget); } + let lock = lock.map(|mutation| LockCommitReceipt { + entry_snapshots: mutation.expected.entry_snapshots.clone(), + root_snapshots: mutation.expected.root_snapshots.clone(), + }); + let warnings = (self.failure == Failure::Cleanup) + .then(|| MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some("backup remains".to_string()), + }) + .into_iter() + .collect(); + Ok(UnitTransactionReceipt { lock, warnings }) }) } } - struct FakeLockCommitter { - log: Arc>>, - failure: Failure, - } - - struct RecordingLockCommitter { + struct RecordingUnitExecutor { expected_entries: Arc>>>, } - impl PreparedLockCommitter for FakeLockCommitter { - fn commit<'a>( + impl PreparedUnitExecutor for RecordingUnitExecutor { + type Prepared = (); + + fn prepare<'a>( &'a self, - mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.log - .lock() - .unwrap() - .push(format!("lock:{}", mutation.skill_name())); - if self.failure == Failure::Lock - || (self.failure == Failure::LockAndRestoreRequired - && mutation.skill_name() == "first") - { - Err(AppError::ExecutionFailed { - message: "lock failed".to_string(), - }) - } else { - Ok(LockCommitReceipt { - entry_snapshots: mutation.expected.entry_snapshots.clone(), - root_snapshots: mutation.expected.root_snapshots.clone(), - }) - } - }) + _unit: &'a ExecutionUnit, + _payloads: &'a BTreeMap, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(()) }) } - } - impl PreparedLockCommitter for RecordingLockCommitter { - fn commit<'a>( + fn execute<'a>( &'a self, - mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + _prepared: Self::Prepared, + mutation: Option<&'a PreparedLockMutation>, + _cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { + let mutation = mutation.expect("fixture unit has a lock mutation"); self.expected_entries .lock() .unwrap() .push(mutation.expected.entry_snapshots.keys().cloned().collect()); - Ok(LockCommitReceipt { - entry_snapshots: mutation.expected.entry_snapshots.clone(), - root_snapshots: mutation.expected.root_snapshots.clone(), + Ok(UnitTransactionReceipt { + lock: Some(LockCommitReceipt { + entry_snapshots: mutation.expected.entry_snapshots.clone(), + root_snapshots: mutation.expected.root_snapshots.clone(), + }), + warnings: Vec::new(), }) }) } @@ -728,13 +754,9 @@ mod tests { fn coordinator( failure: Failure, log: Arc>>, - ) -> MutationCoordinator { + ) -> MutationCoordinator { MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure, - }, - FakeLockCommitter { + FakeUnitExecutor { log: log.clone(), failure, }, @@ -755,7 +777,7 @@ mod tests { } #[tokio::test] - async fn successful_unit_uses_the_exact_transaction_order() { + async fn successful_unit_uses_the_backend_transaction_seam() { let log = Arc::new(Mutex::new(Vec::new())); let results = coordinator(Failure::None, log.clone()) .execute( @@ -769,22 +791,17 @@ mod tests { *log.lock().unwrap(), vec![ "runtime:global", - "stage:one", - "marker:one", + "prepare:one", "runtime:global", - "recheck:one", - "swap:one", - "verify:one", - "lock:one", - "cleanup:one", + "execute:one", ] ); } #[tokio::test] - async fn stale_entry_recheck_stops_before_swap_verify_and_lock() { + async fn backend_transaction_failure_is_returned_without_coordinator_phases() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::Recheck, log.clone()) + let results = coordinator(Failure::Execute, log.clone()) .execute( plan(vec![unit("one", key("one"))]), CancellationSignal::default(), @@ -796,12 +813,9 @@ mod tests { *log.lock().unwrap(), vec![ "runtime:global", - "stage:one", - "marker:one", + "prepare:one", "runtime:global", - "recheck:one", - "restore:one", - "cleanup:one", + "execute:one", ] ); } @@ -837,19 +851,16 @@ mod tests { } #[tokio::test] - async fn lock_failure_restores_before_cleanup() { + async fn backend_transaction_failure_remains_a_failed_unit() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::Lock, log.clone()) + let results = coordinator(Failure::Execute, log.clone()) .execute( plan(vec![unit("one", key("one"))]), CancellationSignal::default(), ) .await; assert_eq!(results[0].status, MutationUnitStatus::Failed); - let log = log.lock().unwrap(); - assert!(log - .windows(3) - .any(|window| { window == ["lock:one", "restore:one", "cleanup:one"] })); + assert!(log.lock().unwrap().iter().any(|item| item == "execute:one")); } #[tokio::test] @@ -873,7 +884,7 @@ mod tests { async fn recovery_required_blocks_only_later_overlapping_units() { let log = Arc::new(Mutex::new(Vec::new())); let shared = key("shared"); - let results = coordinator(Failure::LockAndRestoreRequired, log) + let results = coordinator(Failure::RecoveryRequired, log) .execute( plan(vec![ unit("first", shared.clone()), @@ -889,9 +900,9 @@ mod tests { } #[tokio::test] - async fn one_staging_failure_does_not_abort_independent_units() { + async fn one_prepare_failure_does_not_abort_independent_units() { let log = Arc::new(Mutex::new(Vec::new())); - let results = coordinator(Failure::StageSecond, log.clone()) + let results = coordinator(Failure::PrepareSecond, log.clone()) .execute( plan(vec![ unit("first", key("first")), @@ -906,13 +917,19 @@ mod tests { assert_eq!(results[1].status, MutationUnitStatus::Failed); assert_eq!(results[2].status, MutationUnitStatus::Succeeded); let log = log.lock().unwrap(); - let stage_third = log.iter().position(|entry| entry == "stage:third").unwrap(); - let swap_first = log.iter().position(|entry| entry == "swap:first").unwrap(); - assert!(stage_third < swap_first); + let prepare_third = log + .iter() + .position(|entry| entry == "prepare:third") + .unwrap(); + let execute_first = log + .iter() + .position(|entry| entry == "execute:first") + .unwrap(); + assert!(prepare_third < execute_first); } #[tokio::test] - async fn cancellation_before_commit_cleans_every_staged_unit() { + async fn cancellation_after_prepare_stops_before_every_transaction() { let log = Arc::new(Mutex::new(Vec::new())); let results = coordinator(Failure::CancelAfterSecond, log.clone()) .execute( @@ -928,10 +945,11 @@ mod tests { assert!(results .iter() .all(|result| result.status != MutationUnitStatus::Succeeded)); - let log = log.lock().unwrap(); - assert!(log.iter().any(|entry| entry == "cleanup:first")); - assert!(log.iter().any(|entry| entry == "cleanup:second")); - assert!(!log.iter().any(|entry| entry.starts_with("swap:"))); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry.starts_with("execute:"))); } #[tokio::test] @@ -939,11 +957,7 @@ mod tests { let log = Arc::new(Mutex::new(Vec::new())); let expected_entries = Arc::new(Mutex::new(Vec::new())); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - RecordingLockCommitter { + RecordingUnitExecutor { expected_entries: Arc::clone(&expected_entries), }, FakeRevisions { @@ -979,11 +993,7 @@ mod tests { let log = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - FakeLockCommitter { + FakeUnitExecutor { log, failure: Failure::None, }, @@ -1005,15 +1015,11 @@ mod tests { } #[tokio::test] - async fn selected_binding_change_is_rejected_after_staging_before_swap() { + async fn selected_binding_change_is_rejected_after_prepare_before_execute() { let log = Arc::new(Mutex::new(Vec::new())); let calls = Arc::new(AtomicUsize::new(0)); let coordinator = MutationCoordinator::new( - FakeEntryExecutor { - log: log.clone(), - failure: Failure::None, - }, - FakeLockCommitter { + FakeUnitExecutor { log: log.clone(), failure: Failure::None, }, @@ -1031,6 +1037,10 @@ mod tests { .await; assert_eq!(results[0].status, MutationUnitStatus::Failed); - assert!(!log.lock().unwrap().iter().any(|entry| entry == "swap:one")); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry == "execute:one")); } } diff --git a/src-tauri/src/core/lock_repository.rs b/src-tauri/src/core/lock_repository.rs index a81eed91..22441f8b 100644 --- a/src-tauri/src/core/lock_repository.rs +++ b/src-tauri/src/core/lock_repository.rs @@ -30,6 +30,12 @@ pub struct LockTransaction<'a> { pending_roots: BTreeMap, } +struct ParsedLockSnapshot { + document: LosslessLockDocument, + primary_revision: Option, + primary_generation: Option, +} + impl LockRepository { pub fn new(io: EnvironmentLockIo) -> Self { Self { io } @@ -39,20 +45,44 @@ impl LockRepository { &self, target: &LockTarget, ) -> Result { - if let Some(bytes) = self.io.read_optional(&target.primary).await? { - return LosslessLockDocument::parse(&bytes); + Ok(self.read_document_snapshot(target).await?.document) + } + + async fn read_document_snapshot( + &self, + target: &LockTarget, + ) -> Result { + let primary = self.io.read_optional_snapshot(&target.primary).await?; + if let Some(bytes) = primary.bytes { + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::parse(&bytes)?, + primary_revision: primary.revision, + primary_generation: primary.generation, + }); } let Some(legacy) = target.legacy.as_ref() else { - return Ok(LosslessLockDocument::empty(target.schema)); + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::empty(target.schema), + primary_revision: None, + primary_generation: primary.generation, + }); }; let Some(bytes) = self.io.read_optional(legacy).await? else { - return Ok(LosslessLockDocument::empty(target.schema)); + return Ok(ParsedLockSnapshot { + document: LosslessLockDocument::empty(target.schema), + primary_revision: None, + primary_generation: primary.generation, + }); }; let document = LosslessLockDocument::parse(&bytes)?; - match target.schema { - LockSchema::Global => Ok(document), - LockSchema::Project => convert_legacy_project_document(document), - } + Ok(ParsedLockSnapshot { + document: match target.schema { + LockSchema::Global => document, + LockSchema::Project => convert_legacy_project_document(document)?, + }, + primary_revision: None, + primary_generation: primary.generation, + }) } pub async fn begin( @@ -97,7 +127,8 @@ impl LockTransaction<'_> { root_snapshots, pending_roots, } = self; - let mut latest = repository.read_document(&target).await?; + let latest_snapshot = repository.read_document_snapshot(&target).await?; + let mut latest = latest_snapshot.document; for (field, replacement) in pending_roots { latest.replace_root( &field, @@ -109,7 +140,12 @@ impl LockTransaction<'_> { } repository .io - .write_atomic(&target.primary, latest.to_pretty_bytes()?) + .write_if_revision( + &target.primary, + latest_snapshot.primary_generation, + latest_snapshot.primary_revision, + latest.to_pretty_bytes()?, + ) .await } diff --git a/src-tauri/src/core/lossless_lock.rs b/src-tauri/src/core/lossless_lock.rs index 7cd67662..69408148 100644 --- a/src-tauri/src/core/lossless_lock.rs +++ b/src-tauri/src/core/lossless_lock.rs @@ -42,6 +42,20 @@ pub struct LockEntrySnapshot(Option); pub struct LockRootSnapshot(Option); impl LockEntrySnapshot { + pub(crate) fn from_value(value: Option) -> Self { + Self(value) + } + + pub fn value(&self) -> Option<&Value> { + self.0.as_ref() + } +} + +impl LockRootSnapshot { + pub(crate) fn from_value(value: Option) -> Self { + Self(value) + } + pub fn value(&self) -> Option<&Value> { self.0.as_ref() } @@ -84,6 +98,7 @@ impl LosslessLockDocument { LockRootSnapshot(self.root.get(field).cloned()) } + #[cfg(test)] pub fn replace_entry( &mut self, schema: LockSchema, @@ -105,54 +120,6 @@ impl LosslessLockDocument { Ok(()) } - pub fn remove_entry( - &mut self, - skill_name: &str, - expected: &LockEntrySnapshot, - ) -> Result<(), AppError> { - if self.skills().get(skill_name) != expected.0.as_ref() { - return Err(AppError::LockConflict { - target: LockConflictTarget::Skill { - skill_name: skill_name.to_string(), - }, - }); - } - self.skills_mut().remove(skill_name); - Ok(()) - } - - pub fn move_and_replace_entry( - &mut self, - schema: LockSchema, - from: &str, - to: &str, - expected_from: &LockEntrySnapshot, - expected_to: &LockEntrySnapshot, - replacement: Value, - ) -> Result<(), AppError> { - self.validate_entry_snapshot(from, expected_from)?; - self.validate_entry_snapshot(to, expected_to)?; - let replacement = merge_entry_fields(schema, self.skills().get(from), replacement); - self.skills_mut().remove(from); - self.skills_mut().insert(to.to_string(), replacement); - Ok(()) - } - - pub fn validate_entry_snapshot( - &self, - skill_name: &str, - expected: &LockEntrySnapshot, - ) -> Result<(), AppError> { - if self.skills().get(skill_name) != expected.0.as_ref() { - return Err(AppError::LockConflict { - target: LockConflictTarget::Skill { - skill_name: skill_name.to_string(), - }, - }); - } - Ok(()) - } - pub fn replace_root( &mut self, field: &str, @@ -187,6 +154,7 @@ impl LosslessLockDocument { self.root["skills"].as_object().expect("validated skills") } + #[cfg(test)] fn skills_mut(&mut self) -> &mut Map { self.root["skills"] .as_object_mut() diff --git a/src-tauri/src/core/skill_lock.rs b/src-tauri/src/core/skill_lock.rs index 23b66236..2e502887 100644 --- a/src-tauri/src/core/skill_lock.rs +++ b/src-tauri/src/core/skill_lock.rs @@ -58,11 +58,10 @@ pub fn get_skill_lock_path() -> std::path::PathBuf { #[cfg(test)] mod tests { use super::*; - use once_cell::sync::Lazy; - use std::sync::Mutex; + use std::sync::{LazyLock, Mutex}; use tempfile::tempdir; - static ENV_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); #[test] fn test_get_skill_lock_path() { diff --git a/src-tauri/src/environment/lock_io.rs b/src-tauri/src/environment/lock_io.rs index a1faa397..76c17ce9 100644 --- a/src-tauri/src/environment/lock_io.rs +++ b/src-tauri/src/environment/lock_io.rs @@ -1,13 +1,22 @@ use crate::environment::native::atomic_file::NativeAtomicDocumentIo; use crate::environment::types::ResourceLocator; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; use crate::storage::atomic_document::AtomicDocumentIo; +use sha2::{Digest, Sha256}; + +pub struct LockDocumentSnapshot { + pub bytes: Option>, + pub revision: Option, + pub generation: Option, +} pub enum EnvironmentLockIo { Native, - ActiveWsl(WslSession), + ActiveWsl { + session: Box, + workspace: WslWorkspace, + }, } impl EnvironmentLockIo { @@ -15,12 +24,36 @@ impl EnvironmentLockIo { &self, locator: &ResourceLocator, ) -> Result>, AppError> { + Ok(self.read_optional_snapshot(locator).await?.bytes) + } + + pub async fn read_optional_snapshot( + &self, + locator: &ResourceLocator, + ) -> Result { match self { - Self::Native => NativeAtomicDocumentIo.read_optional(locator).await, - Self::ActiveWsl(session) => { - WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(locator) - .await + Self::Native => { + let bytes = NativeAtomicDocumentIo.read_optional(locator).await?; + let revision = bytes.as_deref().map(document_revision); + Ok(LockDocumentSnapshot { + bytes, + revision, + generation: None, + }) + } + Self::ActiveWsl { session, workspace } => { + require_active_wsl_target(session, locator)?; + let snapshot = workspace + .read_optional_document_snapshot_once( + locator.native_path.clone(), + environment_protocol::MAX_DOCUMENT_BYTES, + ) + .await?; + Ok(LockDocumentSnapshot { + bytes: snapshot.bytes, + revision: snapshot.revision, + generation: Some(snapshot.generation), + }) } } } @@ -34,6 +67,7 @@ impl EnvironmentLockIo { }) } + #[cfg(test)] pub async fn write_atomic( &self, locator: &ResourceLocator, @@ -41,15 +75,74 @@ impl EnvironmentLockIo { ) -> Result<(), AppError> { match self { Self::Native => NativeAtomicDocumentIo.write_atomic(locator, bytes).await, - Self::ActiveWsl(session) => { - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(locator, bytes) + Self::ActiveWsl { session, workspace } => { + let snapshot = self.read_optional_snapshot(locator).await?; + require_active_wsl_target(session, locator)?; + workspace + .write_document_atomic( + snapshot.generation.ok_or(AppError::StaleEnvironment)?, + locator.native_path.clone(), + snapshot.revision, + bytes, + ) + .await + .map(|_| ()) + } + } + } + + pub async fn write_if_revision( + &self, + locator: &ResourceLocator, + expected_generation: Option, + expected_revision: Option, + bytes: Vec, + ) -> Result<(), AppError> { + match self { + Self::Native => { + let current = NativeAtomicDocumentIo.read_optional(locator).await?; + if current.as_deref().map(document_revision) != expected_revision { + return Err(AppError::StaleTarget); + } + NativeAtomicDocumentIo.write_atomic(locator, bytes).await + } + Self::ActiveWsl { session, workspace } => { + require_active_wsl_target(session, locator)?; + workspace + .write_document_atomic( + expected_generation.ok_or(AppError::StaleEnvironment)?, + locator.native_path.clone(), + expected_revision, + bytes, + ) .await + .map(|_| ()) } } } } +fn document_revision(bytes: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +fn require_active_wsl_target( + session: &WslSession, + locator: &ResourceLocator, +) -> Result<(), AppError> { + match &locator.environment { + crate::environment::types::EnvironmentRef::Wsl { distro_name } + if distro_name.eq_ignore_ascii_case(&session.distro_name) + && locator.native_path.starts_with('/') => + { + Ok(()) + } + _ => Err(AppError::StorageUnsupported { + path: locator.native_path.clone(), + }), + } +} + #[cfg(test)] mod tests { use tempfile::tempdir; diff --git a/src-tauri/src/environment/native/materialize.rs b/src-tauri/src/environment/native/materialize.rs index 3e20b7e6..1561f24a 100644 --- a/src-tauri/src/environment/native/materialize.rs +++ b/src-tauri/src/environment/native/materialize.rs @@ -5,7 +5,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; -use crate::application::mutation::coordinator::{BoxFuture, PreparedEntryExecutor}; +use crate::application::mutation::coordinator::{ + BoxFuture, PreparedEntryExecutor, PreparedLockCommitter, PreparedUnitExecutor, + UnitTransactionReceipt, +}; use crate::application::mutation::plan::{ ExecutionUnit, PreparedEntryAction, PreparedEntryMutation, }; @@ -26,6 +29,7 @@ use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::{AppError, RecoveryResourceId}; use crate::models::InstallMode; +use crate::storage::lock_plan::PreparedLockMutation; pub struct NativePreparedEntrySet { entries: NativeEntrySet, @@ -45,6 +49,160 @@ pub struct NativePreparedEntryExecutor { recovery_store: Arc, } +pub struct NativePreparedUnitExecutor { + entries: NativePreparedEntryExecutor, + locks: L, +} + +pub struct PreparedNativeUnit { + unit: ExecutionUnit, + intents: Vec, +} + +impl NativePreparedUnitExecutor { + pub fn new(entries: NativePreparedEntryExecutor, locks: L) -> Self { + Self { entries, locks } + } +} + +impl PreparedUnitExecutor for NativePreparedUnitExecutor +where + L: PreparedLockCommitter, +{ + type Prepared = PreparedNativeUnit; + + fn prepare<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let mut loaded = BTreeMap::new(); + for entry in unit + .primary_entry + .iter() + .chain(unit.additional_entries.iter()) + { + let PreparedEntryAction::Replace { + payload_id, + requested_mode: InstallMode::Copy, + } = &entry.action + else { + continue; + }; + if loaded.contains_key(payload_id) { + continue; + } + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let lease = payloads.get(payload_id).ok_or(AppError::StalePayload)?; + match lease.local_source()? { + PayloadLocalSource::InProcess | PayloadLocalSource::NativeManaged { .. } => {} + PayloadLocalSource::WslManaged { .. } => { + return Err(AppError::CapabilityUnavailable { + capability: "backendLocalPayload".to_string(), + path: None, + }); + } + } + loaded.insert(payload_id.clone(), Arc::new(lease.load_payload().await?)); + } + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + Ok(PreparedNativeUnit { + unit: unit.clone(), + intents: prepare_native_mutations(unit, &loaded, self.entries.backend.clone())?, + }) + }) + } + + fn execute<'a>( + &'a self, + prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + let entries = tokio::task::spawn_blocking(move || stage_entry_set(&prepared.intents)) + .await + .map_err(native_task_error)??; + let recovery = if planned_recovery_paths(&entries).is_empty() { + None + } else { + let marker = native_recovery_marker( + &self.entries.operation_id, + &prepared.unit.id, + RecoverySubject { + operation_kind: self.entries.operation_kind, + skill_name: prepared.unit.skill_name.clone(), + context: prepared.unit.target.clone(), + }, + &entries, + now_epoch_ms(), + )?; + let recovery_ref = match self.entries.recovery_store.create(&marker).await { + Ok(marker_ref) => marker_ref, + Err(error) => { + let cleanup = cleanup_entry_set(entries)?; + if cleanup.is_empty() { + return Err(error); + } + return Err(AppError::ExecutionFailed { + message: format!( + "{error}; native staging cleanup failed: {}", + cleanup.join("; ") + ), + }); + } + }; + Some(NativePreparedRecovery { + recovery_store: Arc::clone(&self.entries.recovery_store), + recovery_marker: Mutex::new(marker), + recovery_ref, + }) + }; + let mut staged = NativePreparedEntrySet { entries, recovery }; + let transaction = async { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } + self.entries.recheck_entries(&staged).await?; + self.entries.swap(&mut staged).await?; + self.entries.verify(&staged).await?; + match lock { + Some(lock) => self.locks.commit(lock).await.map(Some), + None => Ok(None), + } + } + .await; + match transaction { + Ok(lock) => match self.entries.cleanup(staged).await { + Ok(warnings) => Ok(UnitTransactionReceipt { lock, warnings }), + Err(error) => Ok(UnitTransactionReceipt { + lock, + warnings: vec![MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: Some(error.to_string().chars().take(4096).collect()), + }], + }), + }, + Err(primary) => { + let restore = self.entries.restore(&mut staged).await; + let _ = self.entries.cleanup(staged).await; + Err(restore.err().unwrap_or(primary)) + } + } + }) + } +} + impl NativePreparedEntryExecutor { #[cfg(test)] pub fn new( @@ -78,6 +236,7 @@ impl NativePreparedEntryExecutor { impl PreparedEntryExecutor for NativePreparedEntryExecutor { type Staged = NativePreparedEntrySet; + #[cfg(test)] fn stage<'a>( &'a self, unit: &'a ExecutionUnit, diff --git a/src-tauri/src/environment/planning.rs b/src-tauri/src/environment/planning.rs index 07f39ea1..57290803 100644 --- a/src-tauri/src/environment/planning.rs +++ b/src-tauri/src/environment/planning.rs @@ -236,11 +236,15 @@ impl TargetFactResolver for RuntimeTargetFactResolver { .map(|target| target.native_path.clone()) .collect::>(); let cancellation_for_retry = cancellation.clone(); + let workspace = self.environments.workspace(distro_name)?; self.environments .with_session_retry(distro_name, move |session| { let destinations = destinations.clone(); let cancellation = cancellation_for_retry.clone(); - async move { resolve_wsl(&session, &destinations, cancellation).await } + let workspace = workspace.clone(); + async move { + resolve_wsl(&session, &workspace, &destinations, cancellation).await + } }) .await } @@ -258,15 +262,8 @@ impl ContentManifestReader for RuntimeTargetFactResolver { match &target.location.environment { EnvironmentRef::Native => NativeContentManifestReader.read(target).await, EnvironmentRef::Wsl { distro_name } => { - let target = target.clone(); - self.environments - .with_session_retry(distro_name, move |session| { - let target = target.clone(); - async move { - wsl_content_manifest::inspect(&session, &target, None).await - } - }) - .await + let workspace = self.environments.workspace(distro_name)?; + wsl_content_manifest::inspect(&workspace, target, None).await } } }) @@ -320,15 +317,16 @@ pub(crate) fn resolve_native_targets( async fn resolve_wsl( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, logical_destinations: &[String], cancellation: Option, ) -> Result, AppError> { - let projected = project_targets(session, logical_destinations, cancellation.clone()).await?; + let projected = project_targets(workspace, logical_destinations, cancellation.clone()).await?; let physical_paths = projected .iter() .map(|target| target.physical_destination.clone()) .collect::>(); - let entries = inspect_entries(session, &physical_paths, cancellation).await?; + let entries = inspect_entries(workspace, &physical_paths, cancellation).await?; if entries.len() != projected.len() { return Err(protocol_mismatch()); } @@ -368,10 +366,11 @@ async fn resolve_wsl( pub(crate) async fn resolve_wsl_targets( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, logical_destinations: &[String], cancellation: Option, ) -> Result, AppError> { - resolve_wsl(session, logical_destinations, cancellation).await + resolve_wsl(session, workspace, logical_destinations, cancellation).await } fn wsl_fact( diff --git a/src-tauri/src/environment/project_service.rs b/src-tauri/src/environment/project_service.rs index cb2f2f2a..67c2b1c0 100644 --- a/src-tauri/src/environment/project_service.rs +++ b/src-tauri/src/environment/project_service.rs @@ -204,12 +204,8 @@ pub async fn map_environment_path( if let Some(mapped) = registry.map_input_without_process(&distro_name, &path)? { return Ok(mapped); } - registry - .with_session_retry(&distro_name, move |session| { - let path = path.clone(); - async move { map_windows_path_with_wslpath(&session, &path).await } - }) - .await + let workspace = registry.workspace(&distro_name)?; + map_windows_path_with_wslpath(&workspace, &path).await } } } @@ -284,33 +280,29 @@ fn updated_native_project( }) } -#[cfg(test)] -fn parse_wsl_project_storage( - environment: &EnvironmentRef, - project_count: usize, - bytes: &[u8], -) -> Result, AppError> { - projects::parse_project_storage(environment, project_count, bytes) -} - async fn wsl_project_infos( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, bindings: Vec, ) -> Result, AppError> { - projects::project_infos(session, bindings).await + projects::project_infos(session, workspace, bindings).await } pub(crate) async fn read_wsl_projects( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, ) -> Result, AppError> { - projects::read_projects(session).await + projects::read_projects(session, workspace).await } async fn write_wsl_projects( session: &WslSession, + workspace: &crate::environment::wsl::WslWorkspace, projects: Vec, -) -> Result, AppError> { - projects::write_projects(session, projects).await + generation: u64, + expected_revision: Option, +) -> Result<(), AppError> { + projects::write_projects(session, workspace, projects, generation, expected_revision).await } pub async fn list_environment_projects( @@ -323,10 +315,14 @@ pub async fn list_environment_projects( ensure_native_projects_ready(migration)?.read()?, )), EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, |session| async move { - let projects = read_wsl_projects(&session).await?; - wsl_project_infos(&session, projects).await + .with_session_retry(&distro_name, move |session| { + let workspace = workspace.clone(); + async move { + let projects = read_wsl_projects(&session, &workspace).await?; + wsl_project_infos(&session, &workspace, projects).await + } }) .await } @@ -348,28 +344,40 @@ pub async fn add_environment_project( }) } EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let native_path = native_path.clone(); + let workspace = workspace.clone(); async move { let native_path = match map_wsl_input_without_wslpath( &session.distro_name, &native_path, )? { Some(mapped) => mapped, - None => map_windows_path_with_wslpath(&session, &native_path).await?, + None => map_windows_path_with_wslpath(&workspace, &native_path).await?, }; + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; let result = add_project_binding( - read_wsl_projects(&session).await?, + snapshot.projects, native_path, ProjectPathSemantics::Posix, ); - let project = wsl_project_infos(&session, vec![result.project.clone()]) - .await? - .pop() - .expect("one project info"); + let project = + wsl_project_infos(&session, &workspace, vec![result.project.clone()]) + .await? + .pop() + .expect("one project info"); if result.created { - write_wsl_projects(&session, result.projects).await?; + write_wsl_projects( + &session, + &workspace, + result.projects, + snapshot.generation, + snapshot.revision, + ) + .await?; } Ok(AddProjectResult { project, @@ -393,14 +401,25 @@ pub async fn remove_environment_project( ensure_native_projects_ready(migration)?.remove(&project_id)?, )), EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let project_id = project_id.clone(); + let workspace = workspace.clone(); async move { - let projects = - remove_project_binding(read_wsl_projects(&session).await?, &project_id); - let infos = wsl_project_infos(&session, projects.clone()).await?; - write_wsl_projects(&session, projects).await?; + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; + let projects = remove_project_binding(snapshot.projects, &project_id); + let infos = + wsl_project_infos(&session, &workspace, projects.clone()).await?; + write_wsl_projects( + &session, + &workspace, + projects, + snapshot.generation, + snapshot.revision, + ) + .await?; Ok(infos) } }) @@ -423,23 +442,34 @@ pub async fn set_environment_project_cross_storage_warning( updated_native_project(projects, &project_id) } EnvironmentRef::Wsl { distro_name } => { + let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let project_id = project_id.clone(); + let workspace = workspace.clone(); async move { + let snapshot = + projects::read_projects_snapshot(&session, &workspace).await?; let projects = set_project_cross_storage_warning_suppressed( - read_wsl_projects(&session).await?, + snapshot.projects, &project_id, suppressed, ); - let project = wsl_project_infos(&session, projects.clone()) + let project = wsl_project_infos(&session, &workspace, projects.clone()) .await? .into_iter() .find(|project| project.binding.id == project_id) .ok_or_else(|| AppError::PathNotFound { path: project_id.clone(), })?; - write_wsl_projects(&session, projects).await?; + write_wsl_projects( + &session, + &workspace, + projects, + snapshot.generation, + snapshot.revision, + ) + .await?; Ok(project) } }) @@ -475,7 +505,7 @@ mod tests { use super::{ environment_infos_from_wsl_discovery, list_environments_with, map_environment_path, native_environment_info, native_project_info_for_platform, - native_projects_store_from_config, parse_wsl_project_storage, + native_projects_store_from_config, }; use crate::environment::types::{ EnvironmentRef, EnvironmentStatus, RegisteredProject, StorageAccess, @@ -536,37 +566,6 @@ mod tests { assert_eq!(malformed.storage.owner, None); } - #[test] - fn parses_wsl_project_storage_batch_without_guessing_automount_root() { - let session_environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let storage = parse_wsl_project_storage( - &session_environment, - 3, - b"1\0ok\0C:\\Code\\app\0ok\0\\\\wsl.localhost\\Ubuntu\\home\\alice\\app\0error\0\0", - ) - .expect("storage batch"); - - assert_eq!(storage.len(), 3); - assert_eq!(storage[0].access, StorageAccess::CrossStorage); - assert_eq!(storage[0].owner, Some(EnvironmentRef::Native)); - assert_eq!(storage[1].access, StorageAccess::Native); - assert_eq!(storage[1].owner, Some(session_environment)); - assert_eq!(storage[2].access, StorageAccess::Unsupported); - assert_eq!(storage[2].owner, None); - } - - #[test] - fn rejects_malformed_wsl_project_storage_batches() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - - assert!(parse_wsl_project_storage(&environment, 1, b"2\0ok\0C:\\app\0").is_err()); - assert!(parse_wsl_project_storage(&environment, 2, b"1\0ok\0C:\\app\0").is_err()); - } - #[test] fn unavailable_wsl_discovery_keeps_the_native_environment() { let registry = WslRuntime::default(); diff --git a/src-tauri/src/environment/wsl/operations/atomic_file.rs b/src-tauri/src/environment/wsl/operations/atomic_file.rs index 2b71f0ab..1e1b47a8 100644 --- a/src-tauri/src/environment/wsl/operations/atomic_file.rs +++ b/src-tauri/src/environment/wsl/operations/atomic_file.rs @@ -1,29 +1,18 @@ -#[cfg(all(test, target_os = "linux"))] -use std::path::{Path, PathBuf}; - -use tokio::time::Duration; - use crate::environment::types::{EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, -}; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; -const READ_SCRIPT: &str = include_str!("../scripts/atomic-file.sh"); -pub(crate) const WRITE_SCRIPT: &str = include_str!("../scripts/atomic-file.sh"); -const READ_OPERATION: WslOperationDescriptor = wsl_operation("atomic-file", "read", READ_SCRIPT); -const WRITE_OPERATION: WslOperationDescriptor = wsl_operation("atomic-file", "write", WRITE_SCRIPT); - pub struct WslAtomicDocumentIo { access: WslAtomicDocumentAccess, } enum WslAtomicDocumentAccess { Workspace(WslWorkspace), - Session(WslSession), + Active { + session: WslSession, + workspace: WslWorkspace, + }, } impl WslAtomicDocumentIo { @@ -33,49 +22,23 @@ impl WslAtomicDocumentIo { } } - pub(crate) fn from_active_session(session: WslSession) -> Self { + pub(crate) fn from_active_session(session: WslSession, workspace: WslWorkspace) -> Self { Self { - access: WslAtomicDocumentAccess::Session(session), + access: WslAtomicDocumentAccess::Active { session, workspace }, } } - async fn run( - &self, - operation: &WslOperationDescriptor, - path: &str, - stdin: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - let execute = |session, stdin: Vec| async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args: vec![path.to_string()], - stdin, - timeout: Duration::from_secs(10), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await - .map(|output| output.stdout) - }; + fn workspace(&self) -> &WslWorkspace { match &self.access { - WslAtomicDocumentAccess::Workspace(workspace) => { - workspace - .with_session_retry(move |session| execute(session, stdin.clone())) - .await - } - WslAtomicDocumentAccess::Session(session) => execute(session.clone(), stdin).await, + WslAtomicDocumentAccess::Workspace(workspace) + | WslAtomicDocumentAccess::Active { workspace, .. } => workspace, } } fn path<'a>(&self, target: &'a ResourceLocator) -> Result<&'a str, AppError> { let expected_distro_name = match &self.access { WslAtomicDocumentAccess::Workspace(workspace) => workspace.distro_name(), - WslAtomicDocumentAccess::Session(session) => &session.distro_name, + WslAtomicDocumentAccess::Active { session, .. } => &session.distro_name, }; match &target.environment { EnvironmentRef::Wsl { distro_name } @@ -97,15 +60,12 @@ impl AtomicDocumentIo for WslAtomicDocumentIo { target: &'a ResourceLocator, ) -> IoFuture<'a, Result>, AppError>> { Box::pin(async move { - let output = self - .run( - &READ_OPERATION, - self.path(target)?, - Vec::new(), - DEFAULT_WSL_STDOUT_LIMIT, + self.workspace() + .read_optional_document( + self.path(target)?.to_string(), + environment_protocol::MAX_DOCUMENT_BYTES, ) - .await?; - parse_read_response(&output) + .await }) } @@ -115,207 +75,18 @@ impl AtomicDocumentIo for WslAtomicDocumentIo { bytes: Vec, ) -> IoFuture<'a, Result<(), AppError>> { Box::pin(async move { - let output = self - .run(&WRITE_OPERATION, self.path(target)?, bytes, 32) + let path = self.path(target)?.to_string(); + let snapshot = self + .workspace() + .read_optional_document_snapshot_once( + path.clone(), + environment_protocol::MAX_DOCUMENT_BYTES, + ) .await?; - parse_write_response(&output) + self.workspace() + .write_document_atomic(snapshot.generation, path, snapshot.revision, bytes) + .await + .map(|_| ()) }) } } - -pub fn parse_read_response(bytes: &[u8]) -> Result>, AppError> { - let (version, rest) = take_field(bytes)?; - let (exists, body) = take_field(rest)?; - if version != b"1" { - return Err(protocol_error()); - } - match exists { - b"0" if body.is_empty() => Ok(None), - b"1" => Ok(Some(body.to_vec())), - _ => Err(protocol_error()), - } -} - -pub fn parse_write_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0").then_some(()).ok_or_else(protocol_error) -} - -fn take_field(bytes: &[u8]) -> Result<(&[u8], &[u8]), AppError> { - let index = bytes - .iter() - .position(|byte| *byte == 0) - .ok_or_else(protocol_error)?; - Ok((&bytes[..index], &bytes[index + 1..])) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL atomic document protocol response".to_string(), - } -} - -#[cfg(all(test, target_os = "linux"))] -fn backup_path(path: &Path) -> PathBuf { - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(".bak"); - path.with_file_name(name) -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "原子文件协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::fs; - use std::io::Write; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - - use super::*; - - fn run_write(path: &Path, content: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_SCRIPT) - .arg("--") - .arg("write") - .arg(path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("write script"); - child.stdin.take().unwrap().write_all(content).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - parse_write_response(&output.stdout).expect("write response"); - } - - fn run_read(path: &Path) -> Vec { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(READ_SCRIPT) - .arg("--") - .arg("read") - .arg(path) - .output() - .expect("read script"); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - output.stdout - } - - #[test] - fn posix_atomic_write_leaves_no_sidecar() { - let temp = tempdir().expect("temp"); - let path = temp.path().join("state/document.json"); - run_write(&path, &[0, 1, 2]); - assert_eq!(fs::read(&path).unwrap(), [0, 1, 2]); - assert!(!backup_path(&path).exists()); - - fs::write(backup_path(&path), b"legacy backup").expect("legacy backup"); - run_write(&path, &[3, 0, 4]); - assert_eq!(fs::read(&path).unwrap(), [3, 0, 4]); - assert!(!backup_path(&path).exists()); - - run_write(&path, &[5]); - assert_eq!(fs::read(&path).unwrap(), [5]); - assert!(!backup_path(&path).exists()); - assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); - } - - #[test] - fn optional_read_parser_preserves_binary_body_and_rejects_invalid_header() { - assert_eq!(parse_read_response(b"1\0\x30\0").unwrap(), None); - assert_eq!( - parse_read_response(&[b'1', 0, b'1', 0, 0, 255, 1]).unwrap(), - Some(vec![0, 255, 1]) - ); - assert!(parse_read_response(b"2\0\x31\0data").is_err()); - } - - #[test] - fn optional_read_script_separates_protocol_fields_before_file_content() { - let temp = tempdir().expect("temp"); - let path = temp.path().join("projects.json"); - fs::write(&path, br#"{"schemaVersion":1}"#).expect("fixture document"); - - assert_eq!( - parse_read_response(&run_read(&path)).expect("read response"), - Some(br#"{"schemaVersion":1}"#.to_vec()) - ); - assert_eq!( - parse_read_response(&run_read(&temp.path().join("missing.json"))) - .expect("missing response"), - None - ); - } - - #[cfg(unix)] - #[test] - fn atomic_write_does_not_replace_the_document_when_durability_fails() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempdir().expect("temp"); - let path = temp.path().join("state/document.json"); - fs::create_dir_all(path.parent().expect("parent")).expect("state directory"); - fs::write(&path, b"previous").expect("existing document"); - let bin = temp.path().join("bin"); - fs::create_dir(&bin).expect("bin"); - let sync = bin.join("sync"); - fs::write(&sync, b"#!/bin/sh\nexit 1\n").expect("failing sync"); - fs::set_permissions(&sync, fs::Permissions::from_mode(0o755)).expect("sync mode"); - let path_env = format!( - "{}:{}", - bin.display(), - std::env::var("PATH").unwrap_or_default() - ); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_SCRIPT) - .arg("--") - .arg("write") - .arg(&path) - .env("PATH", path_env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("write script"); - child - .stdin - .take() - .expect("stdin") - .write_all(b"replacement") - .expect("replacement"); - let output = child.wait_with_output().expect("write result"); - - assert!(!output.status.success()); - assert_eq!(fs::read(&path).expect("preserved document"), b"previous"); - } -} - -#[cfg(all(test, not(target_os = "linux")))] -mod portable_tests { - use super::parse_read_response; - - #[test] - fn optional_read_parser_preserves_binary_body_and_rejects_invalid_header() { - assert_eq!(parse_read_response(b"1\0\x30\0").unwrap(), None); - assert_eq!( - parse_read_response(&[b'1', 0, b'1', 0, 0, 255, 1]).unwrap(), - Some(vec![0, 255, 1]) - ); - assert!(parse_read_response(b"2\0\x31\0data").is_err()); - } -} diff --git a/src-tauri/src/environment/wsl/operations/document.rs b/src-tauri/src/environment/wsl/operations/document.rs new file mode 100644 index 00000000..489ff549 --- /dev/null +++ b/src-tauri/src/environment/wsl/operations/document.rs @@ -0,0 +1,336 @@ +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; +use sha2::{Digest, Sha256}; +use tokio::time::Duration; + +const DOCUMENT_READ_DEADLINE_MILLIS: u64 = 20_000; +const DOCUMENT_WRITE_DEADLINE_MILLIS: u64 = 30_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentQuery { + pub path: String, + pub limit: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DocumentState { + Missing, + NotFile, + Unreadable, + Bytes(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentFact { + pub path: String, + pub state: DocumentState, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OptionalDocumentSnapshot { + pub bytes: Option>, + pub revision: Option, + pub generation: u64, +} + +impl WslWorkspace { + pub(crate) async fn read_documents( + &self, + queries: Vec, + aggregate_limit: u32, + ) -> Result, AppError> { + validate_document_queries(&queries, aggregate_limit)?; + let response: environment_protocol::DocumentReadResponse = self + .request_worker_payload(document_read_message(&queries, aggregate_limit)) + .await?; + document_facts(queries, response) + } + + async fn read_documents_once( + &self, + queries: Vec, + aggregate_limit: u32, + ) -> Result<(u64, Vec), AppError> { + validate_document_queries(&queries, aggregate_limit)?; + let (generation, response): (u64, environment_protocol::DocumentReadResponse) = self + .request_worker_payload_once( + document_read_message(&queries, aggregate_limit), + environment_protocol::MAX_RESPONSE_TRANSFER_BYTES, + None, + Duration::from_millis(DOCUMENT_READ_DEADLINE_MILLIS), + ) + .await?; + Ok((generation, document_facts(queries, response)?)) + } + + pub(crate) async fn read_optional_document( + &self, + path: String, + limit: u32, + ) -> Result>, AppError> { + let mut facts = self + .read_documents( + vec![DocumentQuery { + path: path.clone(), + limit, + }], + limit, + ) + .await?; + Ok(optional_snapshot(facts.pop().expect("one document query returns one fact"), 0)?.bytes) + } + + pub(crate) async fn read_optional_document_snapshot_once( + &self, + path: String, + limit: u32, + ) -> Result { + let (generation, mut facts) = self + .read_documents_once( + vec![DocumentQuery { + path: path.clone(), + limit, + }], + limit, + ) + .await?; + optional_snapshot( + facts.pop().expect("one document query returns one fact"), + generation, + ) + } + + pub(crate) async fn write_document_atomic( + &self, + generation: u64, + path: String, + expected_revision: Option, + bytes: Vec, + ) -> Result { + if !path.starts_with('/') + || bytes.is_empty() + || bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize + { + return Err(AppError::Validation { + field: Some("documentWrite".to_string()), + message: "WSL document write requires an absolute path and bounded content" + .to_string(), + }); + } + let revision = document_revision(&bytes); + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::PrepareDocumentWrite { + request: environment_protocol::DocumentWritePreparation { + path, + expected_revision, + total_bytes: bytes.len() as u64, + sha256: revision.clone(), + deadline_millis: DOCUMENT_WRITE_DEADLINE_MILLIS, + }, + }, + None, + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(document_write_response_error(message, "TransferReady")), + }; + let response = self + .send_worker_transfer_for_generation( + generation, + transfer_id, + &bytes, + environment_protocol::MAX_DOCUMENT_BYTES as usize, + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + ) + .await?; + match response { + environment_protocol::Message::DocumentWritten { + revision: actual_revision, + } if actual_revision == revision => Ok(revision), + message => Err(document_write_response_error(message, "DocumentWritten")), + } + } + + pub(crate) async fn remove_document_if_revision( + &self, + generation: u64, + path: String, + expected_revision: Option, + ) -> Result<(), AppError> { + if !path.starts_with('/') { + return Err(AppError::Validation { + field: Some("documentRemove".to_string()), + message: "WSL document remove requires an absolute path".to_string(), + }); + } + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::RemoveDocument { + request: environment_protocol::DocumentRemoveRequest { + path, + expected_revision, + deadline_millis: DOCUMENT_WRITE_DEADLINE_MILLIS, + }, + }, + None, + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + ) + .await?; + match response { + environment_protocol::Message::DocumentRemoved => Ok(()), + message => Err(document_write_response_error(message, "DocumentRemoved")), + } + } +} + +fn validate_document_queries( + queries: &[DocumentQuery], + aggregate_limit: u32, +) -> Result<(), AppError> { + if queries.is_empty() + || aggregate_limit == 0 + || queries + .iter() + .any(|query| !query.path.starts_with('/') || query.limit == 0) + { + return Err(AppError::Validation { + field: Some("documentRead".to_string()), + message: "WSL document read requires absolute paths and positive limits".to_string(), + }); + } + Ok(()) +} + +fn document_read_message( + queries: &[DocumentQuery], + aggregate_limit: u32, +) -> environment_protocol::Message { + environment_protocol::Message::ReadDocuments { + request: environment_protocol::DocumentReadRequest { + queries: queries + .iter() + .map(|query| environment_protocol::DocumentReadQuery { + path: query.path.clone(), + limit: query.limit, + }) + .collect(), + aggregate_limit, + deadline_millis: DOCUMENT_READ_DEADLINE_MILLIS, + }, + } +} + +fn document_facts( + queries: Vec, + response: environment_protocol::DocumentReadResponse, +) -> Result, AppError> { + let expected_paths = queries + .into_iter() + .map(|query| query.path) + .collect::>(); + if response.facts.len() != expected_paths.len() + || response + .facts + .iter() + .zip(&expected_paths) + .any(|(fact, path)| fact.path != *path) + { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL Worker document response".to_string(), + }); + } + Ok(response + .facts + .into_iter() + .map(|fact| DocumentFact { + path: fact.path, + state: match fact.state { + environment_protocol::DocumentReadState::Missing => DocumentState::Missing, + environment_protocol::DocumentReadState::NotFile => DocumentState::NotFile, + environment_protocol::DocumentReadState::Unreadable => DocumentState::Unreadable, + environment_protocol::DocumentReadState::Bytes(bytes) => { + DocumentState::Bytes(bytes) + } + }, + truncated: fact.truncated, + }) + .collect()) +} + +fn optional_snapshot( + fact: DocumentFact, + generation: u64, +) -> Result { + let path = fact.path; + match fact.state { + DocumentState::Missing => Ok(OptionalDocumentSnapshot { + bytes: None, + revision: None, + generation, + }), + DocumentState::Bytes(bytes) if !fact.truncated => { + let revision = Some(document_revision(&bytes)); + Ok(OptionalDocumentSnapshot { + bytes: Some(bytes), + revision, + generation, + }) + } + DocumentState::Bytes(_) => Err(AppError::ExecutionFailed { + message: format!("document exceeds its read limit: {path}"), + }), + DocumentState::NotFile | DocumentState::Unreadable => Err(AppError::Path { + message: format!("document is not readable: {path}"), + }), + } +} + +fn document_revision(bytes: &[u8]) -> String { + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +fn document_write_response_error( + message: environment_protocol::Message, + expected: &str, +) -> AppError { + match message { + environment_protocol::Message::Error { code, .. } if code == "documentConflict" => { + AppError::StaleTarget + } + environment_protocol::Message::Error { code, .. } if code == "deadlineExceeded" => { + AppError::WslCommandTimedOut + } + environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + _ => AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected} response"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::document_write_response_error; + use crate::error::AppError; + + #[test] + fn document_conflict_maps_to_stale_target() { + let error = document_write_response_error( + environment_protocol::Message::Error { + code: "documentConflict".to_string(), + phase: "documentWrite".to_string(), + parameters: Vec::new(), + }, + "DocumentWritten", + ); + + assert_eq!(error, AppError::StaleTarget); + } +} diff --git a/src-tauri/src/environment/wsl/operations/library.rs b/src-tauri/src/environment/wsl/operations/library.rs new file mode 100644 index 00000000..79b10475 --- /dev/null +++ b/src-tauri/src/environment/wsl/operations/library.rs @@ -0,0 +1,163 @@ +use std::time::Duration; + +use sha2::{Digest, Sha256}; + +use crate::environment::types::EnvironmentRef; +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; + +const LIBRARY_DEADLINE_MILLIS: u64 = 60_000; + +pub struct LibraryCatalogSnapshot { + pub generation: u64, + pub bytes: Option>, + pub revision: Option, +} + +impl WslWorkspace { + pub(crate) async fn read_library_catalog(&self) -> Result>, AppError> { + let response: environment_protocol::LibraryCatalogResponse = self + .request_worker_payload(environment_protocol::Message::ReadLibraryCatalog { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }) + .await?; + validate_catalog_response(&response)?; + Ok(response.present.then_some(response.bytes)) + } + + pub(crate) async fn read_library_catalog_once( + &self, + ) -> Result { + let (generation, response): (u64, environment_protocol::LibraryCatalogResponse) = self + .request_worker_payload_once( + environment_protocol::Message::ReadLibraryCatalog { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }, + environment_protocol::MAX_RESPONSE_TRANSFER_BYTES, + None, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + validate_catalog_response(&response)?; + Ok(LibraryCatalogSnapshot { + generation, + bytes: response.present.then_some(response.bytes), + revision: response.revision, + }) + } + + pub(crate) async fn execute_library_operation( + &self, + generation: u64, + request: environment_protocol::LibraryOperationRequest, + ) -> Result { + if request.deadline_millis != LIBRARY_DEADLINE_MILLIS { + return Err(AppError::Validation { + field: Some("libraryOperation".to_string()), + message: "invalid WSL Library operation deadline".to_string(), + }); + } + let expected_revision = format!("sha256:{:x}", Sha256::digest(&request.catalog_bytes)); + let payload = environment_protocol::encode_payload(&request).map_err(|error| { + AppError::ConfigurationCorrupted { + message: format!("failed to encode WSL Library operation: {error}"), + } + })?; + if payload.is_empty() || payload.len() > environment_protocol::MAX_MUTATION_TRANSFER_BYTES { + return Err(AppError::CapabilityUnavailable { + capability: "wslLibraryOperationSize".to_string(), + path: None, + }); + } + let digest = format!("sha256:{:x}", Sha256::digest(&payload)); + let response = self + .request_worker_control_for_generation( + generation, + environment_protocol::Message::PrepareLibraryOperation { + request: environment_protocol::LibraryOperationPreparation { + total_bytes: payload.len() as u64, + sha256: digest, + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }, + }, + None, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + let transfer_id = match response { + environment_protocol::Message::TransferReady { transfer_id } => transfer_id, + message => return Err(response_error(self.distro_name(), message, "TransferReady")), + }; + let response = self + .send_worker_transfer_for_generation( + generation, + transfer_id, + &payload, + environment_protocol::MAX_MUTATION_TRANSFER_BYTES, + Duration::from_millis(LIBRARY_DEADLINE_MILLIS), + ) + .await?; + match response { + environment_protocol::Message::LibraryOperationCompleted { catalog_revision } + if catalog_revision == expected_revision => + { + Ok(catalog_revision) + } + message => Err(response_error( + self.distro_name(), + message, + "LibraryOperationCompleted", + )), + } + } +} + +fn validate_catalog_response( + response: &environment_protocol::LibraryCatalogResponse, +) -> Result<(), AppError> { + if response.present == response.bytes.is_empty() + || response.present != response.revision.is_some() + { + Err(protocol_error("LibraryCatalogResponse")) + } else { + Ok(()) + } +} + +fn response_error( + distro_name: &str, + message: environment_protocol::Message, + expected: &str, +) -> AppError { + match message { + environment_protocol::Message::Error { code, .. } if code == "staleTarget" => { + AppError::StaleTarget + } + environment_protocol::Message::Error { code, .. } if code == "stalePayload" => { + AppError::StalePayload + } + environment_protocol::Message::Error { code, .. } if code == "deadlineExceeded" => { + AppError::WslCommandTimedOut + } + environment_protocol::Message::Error { code, .. } + if code == "libraryRecoveryIncomplete" => + { + AppError::LibraryRecoveryIncomplete { + environment: EnvironmentRef::Wsl { + distro_name: distro_name.to_string(), + }, + message: "WSL Skill Library recovery is incomplete".to_string(), + } + } + environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { + message: format!("WSL Library operation failed during {phase}: {code}"), + }, + _ => protocol_error(expected), + } +} + +fn protocol_error(expected: &str) -> AppError { + AppError::ConfigurationCorrupted { + message: format!("WSL Worker returned an invalid {expected}"), + } +} diff --git a/src-tauri/src/environment/wsl/operations/library_content.rs b/src-tauri/src/environment/wsl/operations/library_content.rs deleted file mode 100644 index b6f13c0b..00000000 --- a/src-tauri/src/environment/wsl/operations/library_content.rs +++ /dev/null @@ -1,338 +0,0 @@ -use std::time::Duration; - -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; -use crate::environment::wsl::WslSession; -use crate::error::AppError; - -const SCRIPT: &str = include_str!("../scripts/library-content.sh"); -const RECOVER: WslOperationDescriptor = wsl_operation("library-content", "recover", SCRIPT); -const REPLACE: WslOperationDescriptor = wsl_operation("library-content", "replace", SCRIPT); -const DELETE: WslOperationDescriptor = wsl_operation("library-content", "delete", SCRIPT); -const PREPARE_CATALOG: WslOperationDescriptor = - wsl_operation("library-content", "prepare-catalog", SCRIPT); -const FINALIZE_CATALOG: WslOperationDescriptor = - wsl_operation("library-content", "finalize-catalog", SCRIPT); -const ENSURE_LIBRARIES: WslOperationDescriptor = - wsl_operation("library-content", "ensure-libraries", SCRIPT); -const REMOVE_LIBRARY: WslOperationDescriptor = - wsl_operation("library-content", "remove-library", SCRIPT); -const REMOVE_APPLICATION: WslOperationDescriptor = - wsl_operation("library-content", "remove-application", SCRIPT); - -pub async fn recover_library_content(session: &WslSession) -> Result<(), AppError> { - run(&RECOVER, session, Vec::new(), Vec::new()).await -} - -pub async fn replace_library_skill( - session: &WslSession, - library_id: &str, - skill_name: &str, - archive: Vec, -) -> Result<(), AppError> { - validate_component(library_id)?; - validate_component(skill_name)?; - run( - &REPLACE, - session, - vec![ - library_id.to_string(), - skill_name.to_string(), - uuid::Uuid::new_v4().simple().to_string(), - ], - archive, - ) - .await -} - -pub async fn stage_library_skill_deletion( - session: &WslSession, - library_id: &str, - skill_name: &str, -) -> Result<(), AppError> { - validate_component(library_id)?; - validate_component(skill_name)?; - run( - &DELETE, - session, - vec![ - library_id.to_string(), - skill_name.to_string(), - uuid::Uuid::new_v4().simple().to_string(), - ], - Vec::new(), - ) - .await -} - -pub async fn prepare_library_catalog( - session: &WslSession, - catalog_hash: &str, -) -> Result<(), AppError> { - validate_hash(catalog_hash)?; - run( - &PREPARE_CATALOG, - session, - vec![catalog_hash.to_string()], - Vec::new(), - ) - .await -} - -pub async fn finalize_library_catalog( - session: &WslSession, - catalog_hash: &str, -) -> Result<(), AppError> { - validate_hash(catalog_hash)?; - run( - &FINALIZE_CATALOG, - session, - vec![catalog_hash.to_string()], - Vec::new(), - ) - .await -} - -pub async fn ensure_library_roots( - session: &WslSession, - library_ids: &[String], -) -> Result<(), AppError> { - for library_id in library_ids { - validate_component(library_id)?; - } - run(&ENSURE_LIBRARIES, session, library_ids.to_vec(), Vec::new()).await -} - -pub async fn remove_library(session: &WslSession, library_id: &str) -> Result<(), AppError> { - validate_component(library_id)?; - run( - &REMOVE_LIBRARY, - session, - vec![library_id.to_string()], - Vec::new(), - ) - .await -} - -pub async fn remove_library_application( - session: &WslSession, - project_id: &str, -) -> Result<(), AppError> { - validate_component(project_id)?; - run( - &REMOVE_APPLICATION, - session, - vec![project_id.to_string()], - Vec::new(), - ) - .await -} - -async fn run( - operation: &WslOperationDescriptor, - session: &WslSession, - args: Vec, - stdin: Vec, -) -> Result<(), AppError> { - let output = WslOperationExecutor::execute( - operation, - WslOperationRequest { - session: session.clone(), - args, - stdin, - timeout: Duration::from_secs(120), - stdout_limit: 32, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; - if output.stdout == b"1\0" { - Ok(()) - } else { - Err(AppError::ConfigurationCorrupted { - message: "invalid WSL Skill Library content response".to_string(), - }) - } -} - -fn validate_component(value: &str) -> Result<(), AppError> { - if value.is_empty() || matches!(value, "." | "..") || value.contains(['/', '\\', '\0']) { - return Err(AppError::Validation { - field: Some("libraryStorageComponent".to_string()), - message: "invalid Skill Library storage component".to_string(), - }); - } - Ok(()) -} - -fn validate_hash(value: &str) -> Result<(), AppError> { - if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - Ok(()) - } else { - Err(AppError::Validation { - field: Some("catalogHash".to_string()), - message: "invalid Skill Library catalog hash".to_string(), - }) - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "WSL library protocol tests execute the shipped POSIX shell script directly" -)] -mod tests { - use sha2::{Digest, Sha256}; - use std::io::Write; - use std::process::{Command, Stdio}; - - use super::SCRIPT; - - #[test] - fn script_replaces_and_recovers_library_content_inside_the_managed_root() { - let temp = tempfile::tempdir().unwrap(); - let first = archive(b"---\nname: demo\ndescription: First\n---\nfirst\n"); - let second = archive(b"---\nname: demo\ndescription: Second\n---\nsecond\n"); - - run(temp.path(), "ensure-libraries", &["lib-empty"], &[]); - assert!(temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-empty/skills") - .is_dir()); - - run(temp.path(), "replace", &["lib-1", "demo", "op-1"], &first); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":["first"]}"#); - let skill = temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-1/skills/demo/SKILL.md"); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: First\n---\nfirst\n" - ); - - run(temp.path(), "replace", &["lib-1", "demo", "op-2"], &second); - commit_catalog( - temp.path(), - br#"{"schemaVersion":1,"libraries":["second"]}"#, - ); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: Second\n---\nsecond\n" - ); - run( - temp.path(), - "replace", - &["lib-1", "demo", "op-rollback"], - &first, - ); - run(temp.path(), "recover", &[], &[]); - assert_eq!( - std::fs::read(&skill).unwrap(), - b"---\nname: demo\ndescription: Second\n---\nsecond\n" - ); - run( - temp.path(), - "delete", - &["lib-1", "demo", "delete-rollback"], - &[], - ); - run(temp.path(), "recover", &[], &[]); - assert!(skill.exists()); - - run( - temp.path(), - "delete", - &["lib-1", "demo", "delete-commit"], - &[], - ); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":[]}"#); - assert!(!skill.exists()); - - run(temp.path(), "replace", &["lib-1", "demo", "op-3"], &first); - commit_catalog(temp.path(), br#"{"schemaVersion":1,"libraries":["third"]}"#); - run(temp.path(), "remove-library", &["lib-1"], &[]); - assert!(!temp - .path() - .join(".skill-deck/skill-libraries/libraries/lib-1") - .exists()); - - let orphan = temp - .path() - .join(".skill-deck/skill-libraries/.transactions/orphan"); - std::fs::create_dir_all(&orphan).unwrap(); - run_failure(temp.path(), "recover", &[], &[]); - assert!(orphan.exists()); - } - - fn archive(content: &[u8]) -> Vec { - let mut builder = tar::Builder::new(Vec::new()); - let mut directory = tar::Header::new_gnu(); - directory.set_path("stage").unwrap(); - directory.set_entry_type(tar::EntryType::Directory); - directory.set_mode(0o755); - directory.set_size(0); - directory.set_cksum(); - builder.append(&directory, std::io::empty()).unwrap(); - let mut header = tar::Header::new_gnu(); - header.set_path("stage/SKILL.md").unwrap(); - header.set_entry_type(tar::EntryType::Regular); - header.set_mode(0o644); - header.set_size(content.len() as u64); - header.set_cksum(); - builder.append(&header, content).unwrap(); - builder.finish().unwrap(); - builder.into_inner().unwrap() - } - - fn commit_catalog(home: &std::path::Path, content: &[u8]) { - let hash = format!("{:x}", Sha256::digest(content)); - run(home, "prepare-catalog", &[&hash], &[]); - let root = home.join(".skill-deck/skill-libraries"); - std::fs::write(root.join("catalog.json"), content).unwrap(); - run(home, "finalize-catalog", &[&hash], &[]); - } - - fn run(home: &std::path::Path, subcommand: &str, args: &[&str], stdin: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(SCRIPT) - .arg("--") - .arg(subcommand) - .args(args) - .env("HOME", home) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - } - - fn run_failure(home: &std::path::Path, subcommand: &str, args: &[&str], stdin: &[u8]) { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(SCRIPT) - .arg("--") - .arg(subcommand) - .args(args) - .env("HOME", home) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - let output = child.wait_with_output().unwrap(); - assert!(!output.status.success()); - } -} diff --git a/src-tauri/src/environment/wsl/operations/materialize.rs b/src-tauri/src/environment/wsl/operations/materialize.rs index 1a6e619f..e96e97cd 100644 --- a/src-tauri/src/environment/wsl/operations/materialize.rs +++ b/src-tauri/src/environment/wsl/operations/materialize.rs @@ -1,181 +1,36 @@ use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; use tokio::time::Duration; -use crate::application::mutation::coordinator::{BoxFuture, PreparedEntryExecutor}; +use crate::application::mutation::coordinator::{ + BoxFuture, PreparedUnitExecutor, UnitTransactionReceipt, +}; use crate::application::mutation::plan::{ ExecutionUnit, PreparedEntryAction, PreparedEntryMutation, }; -use crate::application::mutation::result::MutationWarning; +use crate::application::mutation::result::{MutationWarning, MutationWarningCode}; use crate::application::payload_session::{PayloadLocalSource, PinnedPayloadLease}; use crate::core::mutation::CancellationSignal; -use crate::core::skill_payload::{PayloadEntryKind, PayloadId, SkillPayloadManifest}; +use crate::core::skill_payload::{PayloadId, SkillPayloadManifest}; use crate::environment::content_manifest::ContentManifestHash; use crate::environment::recovery::{ RecoveryEntryPhase, RecoveryExpectedEntryState, RecoveryMarker, RecoveryMarkerEntry, - RecoveryMarkerKind, RecoveryMarkerRef, RecoveryMarkerStore, RecoverySubject, - RECOVERY_MARKER_SCHEMA_VERSION, + RecoveryMarkerKind, RecoverySubject, RECOVERY_MARKER_SCHEMA_VERSION, }; use crate::environment::runtime::posix_relative_target; -use crate::environment::runtime::{EntryFingerprint, ExecutionBackend}; +use crate::environment::runtime::{EntryFingerprint, ExecutionBackend, PhysicalParentIdentity}; use crate::environment::types::{EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::operations::content_manifest::inspect_path as inspect_content_manifest; -use crate::environment::wsl::operations::entry::inspect_entries; -use crate::environment::wsl::operations::recovery::WslRecoveryMarkerStore; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, -}; use crate::environment::wsl::WslSession; use crate::error::{AppError, RecoveryResourceId}; - -const MATERIALIZE_SCRIPT: &str = include_str!("../scripts/materialize.sh"); -const STAGE_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "stage", MATERIALIZE_SCRIPT); -const SWAP_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "swap", MATERIALIZE_SCRIPT); -const VERIFY_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "verify", MATERIALIZE_SCRIPT); -const RESTORE_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "restore", MATERIALIZE_SCRIPT); -const CLEANUP_OPERATION: WslOperationDescriptor = - wsl_operation("materialize", "cleanup", MATERIALIZE_SCRIPT); -const MAX_MATERIALIZE_ENTRY_COUNT: usize = 100_000; -const MAX_MATERIALIZE_RECORD_COUNT: usize = 1_000_000; -const MAX_MATERIALIZE_STAGE_REQUEST_BYTES: usize = 16 * 1024 * 1024; - -fn materialize_request_size_error() -> AppError { - AppError::CapabilityUnavailable { - capability: "wslMaterializeRequestSize".to_string(), - path: None, - } -} - -fn append_stage_record(request: &mut Vec, fields: [&str; 7]) -> Result<(), AppError> { - if fields.iter().any(|field| field.as_bytes().contains(&0)) { - return Err(AppError::Validation { - field: Some("materializeStageRequest".to_string()), - message: "WSL materialize stage fields must not contain NUL".to_string(), - }); - } - let record_bytes = fields.iter().try_fold(0usize, |total, field| { - total.checked_add(field.len())?.checked_add(1) - }); - if record_bytes - .and_then(|record_bytes| request.len().checked_add(record_bytes)) - .is_none_or(|total| total > MAX_MATERIALIZE_STAGE_REQUEST_BYTES) - { - return Err(materialize_request_size_error()); - } - for field in fields { - request.extend_from_slice(field.as_bytes()); - request.push(0); - } - Ok(()) -} - -fn materialize_stage_request(entries: &[WslEntryMutation]) -> Result, AppError> { - if entries.len() > MAX_MATERIALIZE_ENTRY_COUNT { - return Err(materialize_request_size_error()); - } - let manifest_records = entries - .iter() - .try_fold(0usize, |count, entry| { - count.checked_add(match &entry.action { - WslEntryAction::Materialize { manifest, .. } => manifest.entries.len(), - _ => 0, - }) - }) - .ok_or_else(materialize_request_size_error)?; - let total_records = 1usize - .checked_add(entries.len()) - .and_then(|count| count.checked_add(manifest_records)) - .ok_or_else(materialize_request_size_error)?; - if total_records > MAX_MATERIALIZE_RECORD_COUNT { - return Err(materialize_request_size_error()); - } - let total_records = total_records.to_string(); - let entry_count = entries.len().to_string(); - let mut request = Vec::new(); - append_stage_record( - &mut request, - ["H", "1", &total_records, &entry_count, "", "", ""], - )?; - for (index, entry) in entries.iter().enumerate() { - let index = format!("{index:06}"); - let (action, source, manifest) = match &entry.action { - WslEntryAction::Keep => ("keep", "", None), - WslEntryAction::Materialize { - payload_root, - manifest, - } => ("materialize", payload_root.as_str(), Some(manifest)), - WslEntryAction::Symlink { target } => ("symlink", target.as_str(), None), - WslEntryAction::Remove => ("remove", "", None), - }; - let manifest_count = manifest - .map_or(0, |manifest| manifest.entries.len()) - .to_string(); - append_stage_record( - &mut request, - [ - "E", - &index, - &entry.destination, - action, - source, - &entry.expected_fingerprint.0, - &manifest_count, - ], - )?; - if let Some(manifest) = manifest { - for manifest_entry in &manifest.entries { - let (kind, blob_id) = match manifest_entry.kind { - PayloadEntryKind::Directory => ("directory", ""), - PayloadEntryKind::File => { - ("file", manifest_entry.blob_id.as_deref().unwrap_or("")) - } - }; - let executable = if manifest_entry.executable { "1" } else { "0" }; - let expected_size = manifest_entry.size.to_string(); - append_stage_record( - &mut request, - [ - "M", - &index, - kind, - &manifest_entry.relative_path, - blob_id, - executable, - &expected_size, - ], - )?; - } - } - } - Ok(request) -} - -fn parse_unit_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0") - .then_some(()) - .ok_or_else(|| AppError::ConfigurationCorrupted { - message: "invalid WSL entry-set protocol response".to_string(), - }) -} +use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WslEntryAction { Keep, - Materialize { - payload_root: String, - manifest: SkillPayloadManifest, - }, - Symlink { - target: String, - }, + Materialize, + Symlink { target: String }, Remove, } @@ -188,21 +43,6 @@ pub struct WslEntryMutation { pub action: WslEntryAction, } -struct PartitionedWslEntrySet { - observations: Vec, - mutations: Vec, -} - -fn partition_entry_set(entries: Vec) -> PartitionedWslEntrySet { - let (observations, mutations) = entries - .into_iter() - .partition(|entry| matches!(entry.action, WslEntryAction::Keep)); - PartitionedWslEntrySet { - observations, - mutations, - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct WslPayloadBinding { pub source: PayloadLocalSource, @@ -213,6 +53,7 @@ pub fn prepare_wsl_mutations( unit: &ExecutionUnit, payloads: &BTreeMap, distro_name: &str, + worker_generation: u64, ) -> Result, AppError> { let environment_matches = matches!( &unit.target.environment, @@ -265,22 +106,27 @@ pub fn prepare_wsl_mutations( if binding.manifest.payload_id() != payload_id { return Err(AppError::StalePayload); } - let payload_root = match &binding.source { + match &binding.source { PayloadLocalSource::WslManaged { distro_name: source_distro, - payload_root, - } if source_distro.eq_ignore_ascii_case(distro_name) => payload_root.clone(), + worker_generation: source_generation, + .. + } if source_distro.eq_ignore_ascii_case(distro_name) + && *source_generation == worker_generation => {} + PayloadLocalSource::WslManaged { + distro_name: source_distro, + .. + } if source_distro.eq_ignore_ascii_case(distro_name) => { + return Err(AppError::StalePayload); + } _ => { return Err(AppError::CapabilityUnavailable { capability: "backendLocalPayload".to_string(), path: None, }) } - }; - WslEntryAction::Materialize { - payload_root, - manifest: binding.manifest.clone(), } + WslEntryAction::Materialize } PreparedEntryAction::Replace { requested_mode: crate::models::InstallMode::Symlink, @@ -389,7 +235,7 @@ pub fn recovery_marker_for_entry_set( }), expected_state: match entry.action { WslEntryAction::Remove => RecoveryExpectedEntryState::Missing, - WslEntryAction::Materialize { .. } | WslEntryAction::Symlink { .. } => { + WslEntryAction::Materialize | WslEntryAction::Symlink { .. } => { RecoveryExpectedEntryState::Present } WslEntryAction::Keep => unreachable!("Keep entries are filtered"), @@ -398,7 +244,7 @@ pub fn recovery_marker_for_entry_set( phase: RecoveryEntryPhase::Staged, }) }) - .collect(); + .collect::>(); Ok(RecoveryMarker { schema_version: RECOVERY_MARKER_SCHEMA_VERSION, resource_id, @@ -412,6 +258,36 @@ pub fn recovery_marker_for_entry_set( }) } +fn add_lock_recovery_evidence( + marker: &mut RecoveryMarker, + entries: &[WslEntryMutation], + environment: &EnvironmentRef, +) -> Result<(), AppError> { + if !marker.entries.is_empty() { + return Ok(()); + } + let evidence = entries.first().ok_or_else(|| AppError::Validation { + field: Some("entrySet".to_string()), + message: "WSL lock mutation requires target evidence".to_string(), + })?; + marker.entries.push(RecoveryMarkerEntry { + physical_target_digest: evidence.physical_target_digest.clone(), + destination: ResourceLocator { + environment: environment.clone(), + native_path: evidence.destination.clone(), + }, + backup: None, + expected_state: if evidence.expected_fingerprint.0 == "entry-v1-missing" { + RecoveryExpectedEntryState::Missing + } else { + RecoveryExpectedEntryState::Present + }, + original_fingerprint: evidence.expected_fingerprint.0.clone(), + phase: RecoveryEntryPhase::Staged, + }); + Ok(()) +} + fn validate_wsl_entry(entry: &PreparedEntryMutation, distro_name: &str) -> Result<(), AppError> { let backend_matches = matches!( &entry.key.backend, @@ -429,63 +305,48 @@ fn validate_wsl_entry(entry: &PreparedEntryMutation, distro_name: &str) -> Resul Ok(()) } -pub struct WslPreparedEntrySet { - session: WslSession, - owner_id: String, - operation_root: String, - entries: Vec, - recovery_store: Arc, - recovery_marker: Mutex>, - recovery_ref: Option, -} - -pub struct WslPreparedEntryExecutor { +pub struct WslPreparedUnitExecutor { session: WslSession, + workspace: crate::environment::wsl::WslWorkspace, operation_id: String, operation_kind: crate::core::mutation::MutationKind, - recovery_store: Arc, } -impl WslPreparedEntryExecutor { - pub fn for_operation( - session: WslSession, - operation_id: impl Into, - operation_kind: crate::core::mutation::MutationKind, - ) -> Self { - let recovery_store = Arc::new(WslRecoveryMarkerStore::from_active_session(session.clone())); - Self::with_recovery_store_for_operation( - session, - operation_id, - operation_kind, - recovery_store, - ) - } +pub struct PreparedWslUnit { + generation: u64, + resource_id: String, + request: environment_protocol::MutationUnitRequest, +} - pub fn with_recovery_store_for_operation( +impl WslPreparedUnitExecutor { + pub fn for_operation( session: WslSession, + workspace: crate::environment::wsl::WslWorkspace, operation_id: impl Into, operation_kind: crate::core::mutation::MutationKind, - recovery_store: Arc, ) -> Self { Self { session, + workspace, operation_id: operation_id.into(), operation_kind, - recovery_store, } } } -impl PreparedEntryExecutor for WslPreparedEntryExecutor { - type Staged = WslPreparedEntrySet; +impl PreparedUnitExecutor for WslPreparedUnitExecutor { + type Prepared = PreparedWslUnit; - fn stage<'a>( + fn prepare<'a>( &'a self, unit: &'a ExecutionUnit, payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { + if cancellation.is_cancelled() { + return Err(AppError::MutationCancelled); + } let bindings = payloads .iter() .map(|(id, lease)| { @@ -498,354 +359,340 @@ impl PreparedEntryExecutor for WslPreparedEntryExecutor { )) }) .collect::, AppError>>()?; - let mutations = prepare_wsl_mutations(unit, &bindings, &self.session.distro_name)?; - stage_entry_set( - &self.session, + let mutations = prepare_wsl_mutations( + unit, + &bindings, + &self.session.distro_name, + self.session.runtime_generation, + )?; + let resource_id = operation_owner_id(&self.operation_id, &unit.id); + let mut marker = recovery_marker_for_entry_set( &self.operation_id, &unit.id, + &resource_id, + &unit.target.environment, RecoverySubject { operation_kind: self.operation_kind, skill_name: unit.skill_name.clone(), context: unit.target.clone(), }, - mutations, - cancellation, - Arc::clone(&self.recovery_store), - ) - .await + &mutations, + now_epoch_ms(), + )?; + if marker.entries.is_empty() && unit.lock_mutation.is_some() { + add_lock_recovery_evidence(&mut marker, &mutations, &unit.target.environment)?; + } + let initial_marker_json = if marker.entries.is_empty() { + Vec::new() + } else { + serde_json::to_vec_pretty(&marker)? + }; + let entries = mutations + .iter() + .map(|mutation| { + let planned = unit + .primary_entry + .iter() + .chain(&unit.additional_entries) + .find(|entry| entry.destination.native_path == mutation.destination) + .ok_or(AppError::StaleTarget)?; + let (expected_anchor_device, expected_anchor_inode) = + match &planned.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&self.session.distro_name) => { + (*device, *inode) + } + _ => return Err(AppError::StaleTarget), + }; + let action = match &mutation.action { + WslEntryAction::Keep => environment_protocol::MutationEntryAction::Keep, + WslEntryAction::Remove => environment_protocol::MutationEntryAction::Remove, + WslEntryAction::Symlink { target } => { + environment_protocol::MutationEntryAction::Symlink { + target: target.clone(), + } + } + WslEntryAction::Materialize => { + let PreparedEntryAction::Replace { payload_id, .. } = &planned.action + else { + return Err(AppError::StalePayload); + }; + let binding = bindings.get(payload_id).ok_or(AppError::StalePayload)?; + let payload_id = match &binding.source { + PayloadLocalSource::WslManaged { + worker_generation, + worker_payload_id, + .. + } if *worker_generation == self.session.runtime_generation => { + *worker_payload_id + } + _ => return Err(AppError::StalePayload), + }; + environment_protocol::MutationEntryAction::Materialize { payload_id } + } + }; + Ok(environment_protocol::MutationEntry { + destination: mutation.destination.clone(), + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: mutation.expected_fingerprint.0.clone(), + expected_content_hash: mutation + .expected_content_manifest_hash + .as_ref() + .map(|hash| hash.as_str().to_string()), + action, + }) + }) + .collect::, AppError>>()?; + Ok(PreparedWslUnit { + generation: self.session.runtime_generation, + resource_id: resource_id.clone(), + request: environment_protocol::MutationUnitRequest { + resource_id, + operation_id: self.operation_id.clone(), + unit_id: unit.id.clone(), + initial_marker_json, + entries, + lock: None, + deadline_millis: 120_000, + }, + }) }) } - fn recheck_entries<'a>( - &'a self, - staged: &'a Self::Staged, - ) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.recheck().await }) - } - - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.swap().await }) - } - - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.verify().await }) - } - - fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { - Box::pin(async move { staged.restore().await }) - } - - fn cleanup<'a>( + fn execute<'a>( &'a self, - staged: Self::Staged, - ) -> BoxFuture<'a, Result, AppError>> { + mut prepared: Self::Prepared, + lock: Option<&'a PreparedLockMutation>, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { Box::pin(async move { - staged.cleanup().await?; - Ok(Vec::new()) + prepared.request.lock = lock + .map(|lock| wire_lock(lock, &self.session.distro_name)) + .transpose()?; + let outcome = self + .workspace + .execute_worker_mutation( + prepared.generation, + &prepared.resource_id, + &prepared.request, + cancellation, + ) + .await?; + match outcome { + environment_protocol::MutationUnitOutcome::Succeeded { lock, cleanup } => { + let mut warnings = Vec::new(); + if let Some(cleanup) = cleanup { + let acknowledged = self + .workspace + .request_worker_control_for_generation( + prepared.generation, + environment_protocol::Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + None, + Duration::from_secs(30), + ) + .await; + if !matches!( + acknowledged, + Ok(environment_protocol::Message::MutationAcknowledged { + ref resource_id + }) if resource_id == &cleanup.resource_id + ) { + warnings.push(MutationWarning { + code: MutationWarningCode::BackupCleanupFailed, + parameters: BTreeMap::new(), + technical_details: acknowledged + .as_ref() + .err() + .map(ToString::to_string), + }); + warnings.push(MutationWarning { + code: MutationWarningCode::CleanupMarkerRetained, + parameters: BTreeMap::new(), + technical_details: None, + }); + } + } + Ok(UnitTransactionReceipt { + lock: lock.map(host_lock_receipt).transpose()?, + warnings, + }) + } + environment_protocol::MutationUnitOutcome::Failed { + code, + phase, + parameters, + message, + } => Err(match code.as_str() { + "staleTarget" => AppError::StaleTarget, + "stalePayload" => AppError::StalePayload, + "deadlineExceeded" => AppError::WslCommandTimedOut, + "lockConflictSkill" => mutation_parameter(¶meters, "skillName") + .map(|skill_name| AppError::LockConflict { + target: crate::error::LockConflictTarget::Skill { + skill_name: skill_name.to_string(), + }, + }) + .unwrap_or_else(invalid_mutation_response), + "lockConflictRoot" => mutation_parameter(¶meters, "field") + .map(|field| AppError::LockConflict { + target: crate::error::LockConflictTarget::RootField { + field: field.to_string(), + }, + }) + .unwrap_or_else(invalid_mutation_response), + _ => AppError::ExecutionFailed { + message: format!("WSL mutation failed during {phase}: {message}"), + }, + }), + environment_protocol::MutationUnitOutcome::Cancelled => { + Err(AppError::MutationCancelled) + } + environment_protocol::MutationUnitOutcome::RecoveryRequired { + resource_id, + message, + } => Err(AppError::RecoveryRequired { + recovery_resource_id: RecoveryResourceId::parse(resource_id).map_err( + |error| AppError::ConfigurationCorrupted { + message: error.to_string(), + }, + )?, + message, + }), + } }) } } -pub async fn stage_entry_set( - session: &WslSession, - operation_id: &str, - unit_id: &str, - subject: RecoverySubject, - entries: Vec, - cancellation: CancellationSignal, - recovery_store: Arc, -) -> Result { - if entries.is_empty() { - return Err(AppError::Validation { - field: Some("entrySet".to_string()), - message: "WSL entry set must not be empty".to_string(), - }); - } - if let Some(entry) = entries - .iter() - .find(|entry| !entry.destination.starts_with('/')) +fn wire_lock( + lock: &PreparedLockMutation, + distro_name: &str, +) -> Result { + if !matches!( + &lock.target.environment, + EnvironmentRef::Wsl { distro_name: target } + if target.eq_ignore_ascii_case(distro_name) + ) || !lock.target.native_path.starts_with('/') + || lock.legacy_target.as_ref().is_some_and(|target| { + !matches!( + &target.environment, + EnvironmentRef::Wsl { distro_name: legacy } + if legacy.eq_ignore_ascii_case(distro_name) + ) || !target.native_path.starts_with('/') + }) { - return Err(AppError::UnsafePath { - path: entry.destination.clone(), - reason: "WSL destination must be an absolute POSIX path".to_string(), - }); - } - let partitioned = partition_entry_set(entries); - recheck_observed_entries( - session, - &partitioned.observations, - Some(cancellation.clone()), - ) - .await?; - let owner_id = operation_owner_id(operation_id, unit_id); - let operation_root = format!("/tmp/skill-deck-operation-{owner_id}"); - if partitioned.mutations.is_empty() { - return Ok(WslPreparedEntrySet { - session: session.clone(), - owner_id, - operation_root, - entries: partitioned.observations, - recovery_store, - recovery_marker: Mutex::new(None), - recovery_ref: None, - }); + return Err(AppError::StaleEnvironment); } - recheck_content_manifests(session, &partitioned.mutations, Some(cancellation.clone())).await?; - let request = materialize_stage_request(&partitioned.mutations)?; - let marker = recovery_marker_for_entry_set( - operation_id, - unit_id, - &owner_id, - &EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + let entry = match &lock.entry { + crate::storage::lock_plan::LockEntryMutation::Replace { key, replacement } => { + environment_protocol::MutationLockEntry::Replace { + key: key.clone(), + replacement_json: serde_json::to_vec(replacement)?, + } + } + crate::storage::lock_plan::LockEntryMutation::Remove { key } => { + environment_protocol::MutationLockEntry::Remove { key: key.clone() } + } + crate::storage::lock_plan::LockEntryMutation::MoveAndReplace { + from, + to, + replacement, + } => environment_protocol::MutationLockEntry::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement_json: serde_json::to_vec(replacement)?, }, - subject, - &partitioned.mutations, - now_epoch_ms(), - )?; - let marker_ref = recovery_store.create(&marker).await?; - let entries = partitioned - .observations - .into_iter() - .chain(partitioned.mutations) - .collect(); - let prepared = WslPreparedEntrySet { - session: session.clone(), - owner_id, - operation_root, - entries, - recovery_store, - recovery_marker: Mutex::new(Some(marker)), - recovery_ref: Some(marker_ref), }; - if let Err(error) = run( - &prepared.session, - &STAGE_OPERATION, - vec![prepared.operation_root.clone(), prepared.owner_id.clone()], - request, - Duration::from_secs(60), - Some(cancellation), - ) - .await - { - let _ = prepared.cleanup().await; - return Err(error); - } - Ok(prepared) -} - -impl WslPreparedEntrySet { - pub async fn recheck(&self) -> Result<(), AppError> { - recheck_observed_entries(&self.session, &self.entries, None).await - } - - pub async fn swap(&mut self) -> Result<(), AppError> { - self.run_static(&SWAP_OPERATION).await?; - self.update_recovery( - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Swapped), - ) - .await - } - - pub async fn verify(&self) -> Result<(), AppError> { - self.run_static(&VERIFY_OPERATION).await?; - let keep = self - .entries - .iter() - .filter(|entry| matches!(entry.action, WslEntryAction::Keep)) - .cloned() - .collect::>(); - recheck_observed_entries(&self.session, &keep, None).await?; - self.update_recovery( - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Verified), - ) - .await - } - - pub async fn restore(&mut self) -> Result<(), AppError> { - match self.run_static(&RESTORE_OPERATION).await { - Ok(()) => { - self.update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await + Ok(environment_protocol::MutationLock { + target: lock.target.native_path.clone(), + legacy_target: lock + .legacy_target + .as_ref() + .map(|target| target.native_path.clone()), + schema: match lock.schema { + crate::core::lossless_lock::LockSchema::Global => { + environment_protocol::MutationLockSchema::Global } - Err(primary) => { - if self - .update_recovery( - RecoveryMarkerKind::RecoveryRequired, - Some(RecoveryEntryPhase::RestoreFailed), - ) - .await - .is_ok() - { - let resource_id = RecoveryResourceId::parse(self.owner_id.clone()) - .expect("operation owner is a SHA-256 recovery ID"); - Err(AppError::RecoveryRequired { - recovery_resource_id: resource_id, - message: primary.to_string(), - }) - } else { - Err(AppError::RestoreFailed { - message: primary.to_string(), - }) - } + crate::core::lossless_lock::LockSchema::Project => { + environment_protocol::MutationLockSchema::Project } - } - } - - pub async fn cleanup(self) -> Result<(), AppError> { - self.update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await?; - self.cleanup_files().await?; - if let Some(marker_ref) = &self.recovery_ref { - self.recovery_store.remove(marker_ref).await?; - } - Ok(()) - } - - async fn run_static(&self, operation: &WslOperationDescriptor) -> Result<(), AppError> { - if self.recovery_ref.is_none() { - return Ok(()); - } - run( - &self.session, - operation, - vec![self.operation_root.clone(), self.owner_id.clone()], - Vec::new(), - Duration::from_secs(30), - None, - ) - .await - } - - async fn cleanup_files(&self) -> Result<(), AppError> { - self.run_static(&CLEANUP_OPERATION).await - } - - async fn update_recovery( - &self, - kind: RecoveryMarkerKind, - phase: Option, - ) -> Result<(), AppError> { - let Some(marker_ref) = &self.recovery_ref else { - return Ok(()); - }; - let marker = self - .recovery_marker - .lock() - .map_err(|_| AppError::Io { - message: "WSL recovery marker state is unavailable".to_string(), - })? - .clone(); - let Some(marker) = marker else { - return Ok(()); - }; - let updated = next_recovery_marker(&marker, kind, phase); - self.recovery_store.update(marker_ref, &updated).await?; - *self.recovery_marker.lock().map_err(|_| AppError::Io { - message: "WSL recovery marker state is unavailable".to_string(), - })? = Some(updated); - Ok(()) - } + }, + entry, + root_replacements_json: lock + .root_replacements + .iter() + .map(|(field, value)| Ok((field.clone(), serde_json::to_vec(value)?))) + .collect::>()?, + expected_entries_json: lock + .expected + .entry_snapshots + .iter() + .map(|(key, snapshot)| { + Ok(( + key.clone(), + snapshot.value().map(serde_json::to_vec).transpose()?, + )) + }) + .collect::>()?, + expected_roots_json: lock + .expected + .root_snapshots + .iter() + .map(|(field, snapshot)| { + Ok(( + field.clone(), + snapshot.value().map(serde_json::to_vec).transpose()?, + )) + }) + .collect::>()?, + }) } -fn validate_observed_entries( - expected: &[WslEntryMutation], - actual: &[crate::environment::wsl::operations::entry::PosixEntryState], -) -> Result<(), AppError> { - if expected.len() != actual.len() - || actual.iter().enumerate().any(|(index, actual)| { - usize::try_from(actual.index).ok() != Some(index) - || actual.fingerprint != expected[index].expected_fingerprint - }) - { - return Err(AppError::StaleTarget); - } - Ok(()) +fn host_lock_receipt( + receipt: environment_protocol::MutationLockReceipt, +) -> Result { + Ok(LockCommitReceipt { + entry_snapshots: receipt + .entries_json + .into_iter() + .map(|(key, value)| { + value + .map(|bytes| serde_json::from_slice(&bytes)) + .transpose() + .map(crate::core::lossless_lock::LockEntrySnapshot::from_value) + .map(|snapshot| (key, snapshot)) + }) + .collect::>()?, + root_snapshots: receipt + .roots_json + .into_iter() + .map(|(field, value)| { + value + .map(|bytes| serde_json::from_slice(&bytes)) + .transpose() + .map(crate::core::lossless_lock::LockRootSnapshot::from_value) + .map(|snapshot| (field, snapshot)) + }) + .collect::>()?, + }) } -async fn recheck_observed_entries( - session: &WslSession, - entries: &[WslEntryMutation], - cancellation: Option, -) -> Result<(), AppError> { - if entries.is_empty() { - return Ok(()); - } - let paths = entries +fn mutation_parameter<'a>(parameters: &'a [(String, String)], name: &str) -> Option<&'a str> { + parameters .iter() - .map(|entry| entry.destination.clone()) - .collect::>(); - let actual = inspect_entries(session, &paths, cancellation.clone()).await?; - validate_observed_entries(entries, &actual)?; - recheck_content_manifests(session, entries, cancellation).await -} - -async fn recheck_content_manifests( - session: &WslSession, - entries: &[WslEntryMutation], - cancellation: Option, -) -> Result<(), AppError> { - for entry in entries { - let Some(expected) = &entry.expected_content_manifest_hash else { - continue; - }; - let actual = inspect_content_manifest(session, &entry.destination, cancellation.clone()) - .await - .map_err(|_| AppError::StaleTarget)?; - validate_content_manifest_hash(expected, actual.hash())?; - } - Ok(()) + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) } -fn validate_content_manifest_hash( - expected: &ContentManifestHash, - actual: &ContentManifestHash, -) -> Result<(), AppError> { - if actual != expected { - return Err(AppError::StaleTarget); - } - Ok(()) -} - -fn next_recovery_marker( - marker: &RecoveryMarker, - kind: RecoveryMarkerKind, - phase: Option, -) -> RecoveryMarker { - let mut updated = marker.clone(); - updated.kind = kind; - if let Some(phase) = phase { - for entry in &mut updated.entries { - entry.phase = phase; - } +fn invalid_mutation_response() -> AppError { + AppError::ConfigurationCorrupted { + message: "WSL Worker mutation response is missing an error parameter".to_string(), } - updated -} - -async fn run( - session: &WslSession, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - timeout: Duration, - cancellation: Option, -) -> Result<(), AppError> { - let output = WslOperationExecutor::execute( - operation, - WslOperationRequest { - session: session.clone(), - args, - stdin, - timeout, - stdout_limit: 32, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation, - }, - ) - .await?; - parse_unit_response(&output.stdout) } fn operation_owner_id(operation_id: &str, unit_id: &str) -> String { @@ -865,1001 +712,223 @@ fn now_epoch_ms() -> u64 { } #[cfg(test)] +mod tests { + use super::*; + use crate::core::mutation::MutationKind; + use crate::environment::types::{SkillLocation, SkillLocationRef}; + + #[test] + fn lock_only_unit_uses_one_keep_entry_as_recovery_evidence() { + let environment = EnvironmentRef::Wsl { + distro_name: "Ubuntu".to_string(), + }; + let subject = RecoverySubject { + operation_kind: MutationKind::Install, + skill_name: "demo".to_string(), + context: SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + }; + let entries = vec![WslEntryMutation { + physical_target_digest: "target-1".to_string(), + destination: "/home/alice/.agents/skills/demo".to_string(), + expected_fingerprint: EntryFingerprint("entry-v1-current".to_string()), + expected_content_manifest_hash: None, + action: WslEntryAction::Keep, + }]; + + let mut marker = recovery_marker_for_entry_set( + "operation-1", + "unit-1", + &"f".repeat(64), + &environment, + subject, + &entries, + 1, + ) + .unwrap(); + + assert!(marker.entries.is_empty()); + add_lock_recovery_evidence(&mut marker, &entries, &environment).unwrap(); + assert_eq!(marker.entries.len(), 1); + assert!(marker.entries[0].backup.is_none()); + assert_eq!( + marker.entries[0].expected_state, + RecoveryExpectedEntryState::Present + ); + } +} + +#[cfg(all(test, target_os = "windows"))] #[allow( clippy::disallowed_methods, - reason = "内容落盘协议测试需要直接运行待验证的 shell 测试脚本" + reason = "真实 WSL 2 Mutation 门禁需要同步启动 wsl.exe 清理测试 fixture" )] -mod tests { +mod windows_worker_mutation_tests { use std::collections::BTreeMap; - use std::fs; - #[cfg(target_os = "linux")] - use std::io::Write; - #[cfg(target_os = "linux")] - use std::process::{Command, Output, Stdio}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use tempfile::tempdir; + use std::process::Stdio; - use super::*; + use crate::application::mutation::coordinator::PreparedUnitExecutor; use crate::application::mutation::plan::{ ExecutionUnit, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, RuntimeRevisions, }; - use crate::application::payload_session::PayloadLocalSource; - use crate::core::agent_definition::AgentId; - use crate::core::skill_payload::{build_skill_payload, SkillPayload}; - use crate::environment::recovery::RecoveryMarkerKind; - use crate::environment::recovery::{RecoveryFuture, RecoveryMarkerLoad}; + use crate::core::mutation::{CancellationSignal, MutationKind}; use crate::environment::runtime::{ ContextSnapshotRevision, ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, }; use crate::environment::types::{ - EnvironmentRef, ResourceLocator, SkillLocation, SkillLocationRef, + normalized_wsl_distro_name, EnvironmentRef, ResourceLocator, SkillLocation, + SkillLocationRef, }; - #[cfg(target_os = "linux")] - use crate::environment::wsl::operations::entry::{parse_entry_states, ENTRY_STATE_SCRIPT}; - use crate::models::InstallMode; + use crate::environment::wsl::operations::entry::inspect_entries; + use crate::environment::wsl::operations::projection::project_targets; + use crate::environment::wsl::WslRuntime; - fn recovery_subject(environment: EnvironmentRef) -> RecoverySubject { - RecoverySubject { - operation_kind: crate::core::mutation::MutationKind::Install, + #[tokio::test] + #[ignore = "requires Windows with a WSL 2 distribution"] + async fn real_wsl2_worker_executes_and_acknowledges_one_remove_transaction() { + let distro = + std::env::var("SKILL_DECK_TEST_WSL_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string()); + let fixture = format!( + "/tmp/skill-deck-mutation-gate-{}", + uuid::Uuid::new_v4().simple() + ); + run_fixture( + &distro, + "set -eu; mkdir -p \"$1/demo\"; printf old > \"$1/demo/SKILL.md\"", + &fixture, + ) + .await; + let _cleanup = FixtureCleanup { + distro: distro.clone(), + path: fixture.clone(), + }; + let runtime = WslRuntime::for_wsl_test(); + let workspace = runtime.workspace(&distro).unwrap(); + let session = runtime.connect(&distro).await.unwrap(); + let destination = format!("{fixture}/demo"); + let projection = project_targets(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap() + .remove(0); + let fingerprint = inspect_entries(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap() + .remove(0) + .fingerprint; + let key = PhysicalTargetKey { + backend: ExecutionBackend::WslPosix { + distro_name: normalized_wsl_distro_name(&distro), + }, + physical_parent: PhysicalParentIdentity::Wsl { + distro_name: normalized_wsl_distro_name(&distro), + device: projection.anchor_device, + inode: projection.anchor_inode, + }, + normalized_final_child_name: "demo".to_string(), + }; + let environment = EnvironmentRef::Wsl { + distro_name: distro.clone(), + }; + let unit = ExecutionUnit { + id: "remove-demo".to_string(), skill_name: "demo".to_string(), - context: SkillLocationRef { - environment, + source: None, + target: SkillLocationRef { + environment: environment.clone(), scope: SkillLocation::Global, }, - } - } + expected_revisions: RuntimeRevisions { + registry: "test".to_string(), + environment: "test".to_string(), + context: ContextSnapshotRevision::parse("context-v1-test").unwrap(), + }, + primary_entry: Some(PreparedEntryMutation { + key: key.clone(), + destination: ResourceLocator { + environment, + native_path: destination.clone(), + }, + action: PreparedEntryAction::Remove, + reader_agent_ids: Vec::new(), + }), + additional_entries: Vec::new(), + lock_mutation: None, + expected_targets: vec![ExpectedTargetEntry { + key, + fingerprint, + expected_content_manifest_hash: None, + }], + }; + let executor = super::WslPreparedUnitExecutor::for_operation( + session, + workspace.clone(), + "mutation-gate", + MutationKind::Remove, + ); - fn payload_fixture(root: &std::path::Path) -> (SkillPayload, std::path::PathBuf) { - let source = root.join("source"); - fs::create_dir_all(source.join("scripts")).unwrap(); - fs::write(source.join("SKILL.md"), b"new").unwrap(); - fs::write(source.join("scripts/run.sh"), b"#!/bin/sh\n").unwrap(); - let payload = build_skill_payload(&source).unwrap(); - let managed = root.join("payload"); - fs::create_dir_all(managed.join("blobs")).unwrap(); - for (id, blob) in &payload.blobs { - fs::write(managed.join("blobs").join(id), blob).unwrap(); - } - (payload, managed) - } - - #[cfg(target_os = "linux")] - fn fingerprint(path: &std::path::Path) -> String { - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENTRY_STATE_SCRIPT) - .arg("--") - .arg("inspect") - .arg(path) - .output() + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await .unwrap(); - parse_entry_states(&output.stdout, 1).unwrap()[0] - .fingerprint - .0 - .clone() - } - - #[cfg(target_os = "linux")] - fn run(script: &str, args: &[String], stdin: &[u8]) -> Output { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(script) - .arg("--") - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() + let receipt = executor + .execute(prepared, None, CancellationSignal::default()) + .await .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - child.wait_with_output().unwrap() - } - - #[test] - fn materialize_stage_request_uses_fixed_width_tagged_records() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let request = materialize_stage_request(&[WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]) - .expect("encode stage request"); - let mut fields = request - .split(|byte| *byte == 0) - .map(|field| String::from_utf8(field.to_vec()).unwrap()) - .collect::>(); - assert_eq!(fields.pop().as_deref(), Some("")); - - assert_eq!(fields[0], "H"); - assert_eq!(fields[1], "1"); - assert_eq!(fields[3], "1"); - assert_eq!(fields.len() % 7, 0); - assert_eq!(fields[7], "E"); - assert_eq!(fields[8], "000000"); - let (records, remainder) = fields.as_chunks::<7>(); - assert!(remainder.is_empty()); - assert!(records.iter().skip(2).all(|record| record[0] == "M")); - } - - #[test] - fn materialize_stage_request_rejects_oversized_stdin_before_transport() { - let destination = format!("/{}", "a".repeat(16 * 1024 * 1024)); - - let result = materialize_stage_request(&[WslEntryMutation { - physical_target_digest: "target-v1-oversized".to_string(), - destination, - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }]); - let error = match result { - Err(error) => error, - Ok(_) => panic!("oversized stage request was accepted"), - }; - - assert!(matches!( - error, - AppError::CapabilityUnavailable { capability, path: None } - if capability == "wslMaterializeRequestSize" - )); - } - - #[cfg(target_os = "linux")] - #[test] - fn materialize_stage_rejects_a_truncated_fixed_width_header() { - let temp = tempdir().unwrap(); - let operation_root = temp.path().join("skill-deck-operation-truncated"); - initialize_operation_root(&operation_root, "truncated"); - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "truncated"), - b"H\0\x31\0\x31\0\x30\0", - ); - - assert!( - !output.status.success(), - "truncated fixed-width header was accepted" - ); - } - - #[tokio::test] - async fn oversized_stage_request_does_not_create_a_recovery_marker() { - let store = Arc::new(CountingRecoveryStore::default()); - let recovery_store: Arc = store.clone(); - let destination = format!("/{}", "a".repeat(16 * 1024 * 1024)); - - let result = stage_entry_set( - &test_session(), - "oversized-operation", - "oversized-unit", - recovery_subject(EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }), - vec![WslEntryMutation { - physical_target_digest: "target-v1-oversized".to_string(), - destination, - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }], - CancellationSignal::default(), - recovery_store, - ) - .await; - let error = match result { - Err(error) => error, - Ok(_) => panic!("oversized stage request was accepted"), - }; + assert!(receipt.warnings.is_empty()); assert!(matches!( - error, - AppError::CapabilityUnavailable { capability, path: None } - if capability == "wslMaterializeRequestSize" + inspect_entries(&workspace, &[destination], None) + .await + .unwrap()[0] + .kind, + crate::environment::wsl::operations::entry::PosixEntryKind::Missing )); - assert_eq!(store.create_count.load(Ordering::SeqCst), 0); } - #[cfg(target_os = "linux")] - #[test] - fn entry_set_stages_swaps_verifies_restores_and_cleans_full_payload() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-1"); - initialize_operation_root(&operation_root, "op-1"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-1"), - &materialize_stage_request(&entries).unwrap(), - ); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-1"), - &[] - ) - .status - .success()); - let verify_output = run( - MATERIALIZE_SCRIPT, - &operation_args("verify", &operation_root, "op-1"), - &[], - ); - assert!( - verify_output.status.success(), - "{}", - String::from_utf8_lossy(&verify_output.stderr) - ); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"new"); - assert_eq!( - fs::read(destination.join("scripts/run.sh")).unwrap(), - b"#!/bin/sh\n" - ); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("restore", &operation_root, "op-1"), - &[] - ) - .status - .success()); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("cleanup", &operation_root, "op-1"), - &[] - ) - .status - .success()); - assert!(!operation_root.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn stale_entry_recheck_prevents_every_final_write_in_the_set() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let operation_root = temp.path().join("skill-deck-operation-op-2"); - let destinations = [ - temp.path().join("targets/first"), - temp.path().join("targets/second"), - ]; - for destination in &destinations { - fs::create_dir_all(destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - } - initialize_operation_root(&operation_root, "op-2"); - let entries = destinations - .iter() - .enumerate() - .map(|(index, destination)| WslEntryMutation { - physical_target_digest: format!("target-v1-{index}"), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }) - .collect::>(); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-2"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - fs::write(destinations[1].join("external"), b"changed").unwrap(); - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-2"), - &[], - ); - - assert!(!output.status.success()); - for destination in &destinations { - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn existing_child_content_change_is_detected_before_wsl_swap() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-content"); - initialize_operation_root(&operation_root, "op-content"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-content".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-content"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - fs::write(destination.join("SKILL.md"), b"locally changed").unwrap(); - - assert!(!run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-content"), - &[] - ) - .status - .success()); - assert_eq!( - fs::read(destination.join("SKILL.md")).unwrap(), - b"locally changed" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn tampered_stage_is_rejected_before_any_wsl_final_write() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let operation_root = temp.path().join("skill-deck-operation-op-tampered"); - let destination = temp.path().join("targets/demo"); - fs::create_dir_all(&destination).unwrap(); - fs::write(destination.join("SKILL.md"), b"old").unwrap(); - initialize_operation_root(&operation_root, "op-tampered"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-demo".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&destination)), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-tampered"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - let stage = destination - .parent() - .unwrap() - .join(".skill-deck-stage-op-tampered-000000"); - fs::write(stage.join("unexpected.txt"), b"tampered").unwrap(); - - assert!(!run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-tampered"), - &[] - ) - .status - .success()); - assert_eq!(fs::read(destination.join("SKILL.md")).unwrap(), b"old"); - assert!(!destination - .parent() - .unwrap() - .join(".skill-deck-backup-op-tampered-000000") - .exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn symlink_and_remove_share_the_same_swap_and_restore_boundary() { - let temp = tempdir().unwrap(); - let operation_root = temp.path().join("skill-deck-operation-op-3"); - let canonical = temp.path().join("canonical/demo"); - let link = temp.path().join("agents/demo"); - let removed = temp.path().join("duplicates/demo"); - fs::create_dir_all(&canonical).unwrap(); - fs::create_dir_all(link.parent().unwrap()).unwrap(); - fs::create_dir_all(&removed).unwrap(); - fs::write(canonical.join("SKILL.md"), b"canonical").unwrap(); - fs::write(removed.join("SKILL.md"), b"private").unwrap(); - initialize_operation_root(&operation_root, "op-3"); - let entries = vec![ - WslEntryMutation { - physical_target_digest: "target-v1-link".to_string(), - destination: link.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&link)), - expected_content_manifest_hash: None, - action: WslEntryAction::Symlink { - target: "../canonical/demo".to_string(), - }, - }, - WslEntryMutation { - physical_target_digest: "target-v1-remove".to_string(), - destination: removed.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint(fingerprint(&removed)), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }, - ]; - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-3"), - &materialize_stage_request(&entries).unwrap() - ) - .status - .success()); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("verify", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); - assert_eq!(fs::read(link.join("SKILL.md")).unwrap(), b"canonical"); - assert!(!removed.exists()); - - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("restore", &operation_root, "op-3"), - &[] - ) - .status - .success()); - assert!(link.symlink_metadata().is_err()); - assert_eq!(fs::read(removed.join("SKILL.md")).unwrap(), b"private"); - } - - #[cfg(target_os = "linux")] - #[test] - fn stage_creates_a_missing_configured_root_before_staging_the_skill_child() { - let temp = tempdir().unwrap(); - let (payload, managed) = payload_fixture(temp.path()); - let destination = temp.path().join(".custom/skills/demo"); - let operation_root = temp.path().join("skill-deck-operation-op-missing-root"); - initialize_operation_root(&operation_root, "op-missing-root"); - let entries = vec![WslEntryMutation { - physical_target_digest: "target-v1-missing-root".to_string(), - destination: destination.to_string_lossy().into_owned(), - expected_fingerprint: EntryFingerprint("entry-v1-missing".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Materialize { - payload_root: managed.to_string_lossy().into_owned(), - manifest: payload.manifest(), - }, - }]; - - let output = run( - MATERIALIZE_SCRIPT, - &operation_args("stage", &operation_root, "op-missing-root"), - &materialize_stage_request(&entries).unwrap(), - ); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(destination.parent().unwrap().is_dir()); - assert!(!destination.exists()); - assert!(run( - MATERIALIZE_SCRIPT, - &operation_args("swap", &operation_root, "op-missing-root"), - &[] - ) - .status - .success()); - assert!(destination.join("SKILL.md").is_file()); - } - - #[test] - fn generic_unit_maps_to_one_wsl_entry_set_without_backend_branches_in_services() { - let temp = tempdir().unwrap(); - let (payload, _) = payload_fixture(temp.path()); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Keep, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Replace { - payload_id: payload.payload_id.clone(), - requested_mode: InstallMode::Symlink, - }, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-1".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-test").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - let bindings = BTreeMap::from([( - payload.payload_id.clone(), - WslPayloadBinding { - source: PayloadLocalSource::WslManaged { - distro_name: "Ubuntu".to_string(), - payload_root: "/tmp/skill-deck-source-session/payload-demo".to_string(), - }, - manifest: payload.manifest(), - }, - )]); - - let mapped = prepare_wsl_mutations(&unit, &bindings, "Ubuntu").unwrap(); - - assert_eq!(mapped.len(), 2); - assert!(matches!(mapped[0].action, WslEntryAction::Keep)); - assert_eq!( - mapped[1].action, - WslEntryAction::Symlink { - target: "../../.agents/skills/demo".to_string(), - } - ); - let recovery = recovery_marker_for_entry_set( - "operation-1", - "unit-1", - "recovery-id", - &environment, - recovery_subject(environment.clone()), - &mapped, - 123, - ) - .unwrap(); - assert_eq!(recovery.kind, RecoveryMarkerKind::InProgress); - assert_eq!(recovery.entries.len(), 1); - assert!(recovery.entries.iter().all(|entry| { - entry.backup.as_ref().is_some_and(|backup| { - backup - .native_path - .contains(".skill-deck-backup-recovery-id-") - }) - })); - let verified = next_recovery_marker( - &recovery, - RecoveryMarkerKind::InProgress, - Some(RecoveryEntryPhase::Verified), - ); - let cleanup = next_recovery_marker(&verified, RecoveryMarkerKind::CleanupOnly, None); - assert_eq!(cleanup.kind, RecoveryMarkerKind::CleanupOnly); - assert!(cleanup - .entries - .iter() - .all(|entry| entry.phase == RecoveryEntryPhase::Verified)); - } - - #[test] - fn keep_entries_are_observed_without_entering_the_materialize_stage() { - let keep = WslEntryMutation { - physical_target_digest: "target-v1-keep".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-keep".to_string()), - expected_content_manifest_hash: Some( - crate::environment::content_manifest::ContentManifest::from_records(Vec::new()) - .unwrap() - .hash() - .clone(), - ), - action: WslEntryAction::Keep, - }; - let remove = WslEntryMutation { - physical_target_digest: "target-v1-remove".to_string(), - destination: "/home/alice/.claude/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-remove".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Remove, - }; - - let prepared = partition_entry_set(vec![keep.clone(), remove.clone()]); - - assert_eq!(prepared.observations, vec![keep]); - assert_eq!(prepared.mutations, vec![remove]); - let keep_only = partition_entry_set(prepared.observations.clone()); - assert!(keep_only.mutations.is_empty()); - } - - #[test] - fn replaced_keep_entry_is_rejected_as_stale() { - let expected = WslEntryMutation { - physical_target_digest: "target-v1-keep".to_string(), - destination: "/home/alice/.agents/skills/demo".to_string(), - expected_fingerprint: EntryFingerprint("entry-v1-before".to_string()), - expected_content_manifest_hash: None, - action: WslEntryAction::Keep, - }; - let actual = crate::environment::wsl::operations::entry::PosixEntryState { - index: 0, - kind: crate::environment::wsl::operations::entry::PosixEntryKind::Directory, - fingerprint: EntryFingerprint("entry-v1-after".to_string()), - link_target: None, - }; - - assert_eq!( - validate_observed_entries(std::slice::from_ref(&expected), &[actual]), - Err(AppError::StaleTarget) - ); - } - - #[test] - fn changed_keep_content_manifest_is_rejected_as_stale() { - let expected = - crate::environment::content_manifest::ContentManifest::from_records(Vec::new()) - .unwrap(); - let actual = crate::environment::content_manifest::ContentManifest::from_records(vec![ - crate::environment::content_manifest::ContentManifestRecord::directory("scripts") - .unwrap(), - ]) - .unwrap(); - - assert_eq!( - validate_content_manifest_hash(expected.hash(), actual.hash()), - Err(AppError::StaleTarget) - ); - } - - #[test] - fn generic_unit_maps_an_explicit_managed_directory_link() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Link { - target: ResourceLocator { - environment: environment.clone(), - native_path: "/home/alice/.local/share/skill-deck/libraries/lib-1/skills/demo" - .to_string(), - }, - }, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Keep, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-explicit-link".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment: environment.clone(), - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-explicit-link").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - - let mapped = prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu").unwrap(); - - assert_eq!( - mapped[0].action, - WslEntryAction::Symlink { - target: "../../.local/share/skill-deck/libraries/lib-1/skills/demo".to_string(), - } - ); - } - - #[test] - fn explicit_managed_directory_link_rejects_another_wsl_environment() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Link { - target: ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: "Debian".to_string(), - }, - native_path: "/home/alice/.local/share/skill-deck/libraries/lib-1/skills/demo" - .to_string(), - }, - }, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Keep, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "unit-cross-environment-link".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment, - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-cross-environment") - .unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - - assert!(matches!( - prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu"), - Err(AppError::StaleEnvironment) - )); - } - - #[test] - fn remove_unit_stages_agent_entry_before_primary_entry() { - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let canonical = mutation( - "canonical", - "/home/alice/.agents/skills/demo", - PreparedEntryAction::Remove, - &environment, - ); - let agent = mutation( - "agent", - "/home/alice/.claude/skills/demo", - PreparedEntryAction::Remove, - &environment, - ); - let expected_targets = [&canonical, &agent] - .into_iter() - .map(|entry| ExpectedTargetEntry { - key: entry.key.clone(), - fingerprint: EntryFingerprint(format!( - "entry-v1-{}", - entry.key.normalized_final_child_name - )), - expected_content_manifest_hash: None, - }) - .collect(); - let unit = ExecutionUnit { - id: "remove-demo".to_string(), - skill_name: "demo".to_string(), - source: None, - target: SkillLocationRef { - environment, - scope: SkillLocation::Global, - }, - expected_revisions: RuntimeRevisions { - registry: "registry-1".to_string(), - environment: "environment-1".to_string(), - context: ContextSnapshotRevision::parse("context-v1-wsl-remove").unwrap(), - }, - primary_entry: Some(canonical), - additional_entries: vec![agent], - lock_mutation: None, - expected_targets, - }; - - let mapped = prepare_wsl_mutations(&unit, &BTreeMap::new(), "Ubuntu").unwrap(); - - assert_eq!(mapped.len(), 2); - assert_eq!(mapped[0].destination, "/home/alice/.claude/skills/demo"); - assert_eq!(mapped[1].destination, "/home/alice/.agents/skills/demo"); - } - - fn mutation( - name: &str, - path: &str, - action: PreparedEntryAction, - environment: &EnvironmentRef, - ) -> PreparedEntryMutation { - PreparedEntryMutation { - key: PhysicalTargetKey { - backend: ExecutionBackend::WslPosix { - distro_name: "Ubuntu".to_string(), - }, - physical_parent: PhysicalParentIdentity::Wsl { - distro_name: "Ubuntu".to_string(), - device: 1, - inode: if name == "canonical" { 1 } else { 2 }, - }, - normalized_final_child_name: name.to_string(), - }, - destination: ResourceLocator { - environment: environment.clone(), - native_path: path.to_string(), - }, - action, - reader_agent_ids: vec![AgentId::parse("claude-code").unwrap()], - } - } - - #[cfg(target_os = "linux")] - fn initialize_operation_root(root: &std::path::Path, id: &str) { - fs::create_dir_all(root).unwrap(); - fs::write(root.join(".skill-deck-owner"), format!("1\n{id}\n")).unwrap(); - fs::write(root.join("recovery.json"), b"{}").unwrap(); - } - - #[cfg(target_os = "linux")] - fn operation_args(subcommand: &str, root: &std::path::Path, id: &str) -> Vec { - vec![ - subcommand.to_string(), - root.to_string_lossy().into_owned(), - id.to_string(), - ] - } - - fn test_session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[derive(Default)] - struct CountingRecoveryStore { - create_count: AtomicUsize, - } - - impl RecoveryMarkerStore for CountingRecoveryStore { - fn environment(&self) -> EnvironmentRef { - EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - } - } - - fn create<'a>( - &'a self, - marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result> { - self.create_count.fetch_add(1, Ordering::SeqCst); - Box::pin(async move { - Ok(RecoveryMarkerRef { - resource_id: marker.resource_id.clone(), - environment: marker.environment.clone(), - managed_root: ResourceLocator { - environment: marker.environment.clone(), - native_path: format!( - "/tmp/skill-deck-operation-{}", - marker.resource_id.as_str() - ), - }, - }) - }) - } - - fn update<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - _marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) - } - - fn enumerate<'a>( - &'a self, - ) -> RecoveryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) - } - - fn remove<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) - } - - fn cleanup<'a>( - &'a self, - _marker_ref: &'a RecoveryMarkerRef, - _marker: &'a RecoveryMarker, - ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) + async fn run_fixture(distro: &str, script: &str, path: &str) { + let status = crate::environment::wsl::wsl_command() + .args([ + "--distribution", + distro, + "--exec", + "/bin/sh", + "-c", + script, + "--", + path, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .await + .unwrap(); + assert!(status.success()); + } + + struct FixtureCleanup { + distro: String, + path: String, + } + + impl Drop for FixtureCleanup { + fn drop(&mut self) { + let _ = std::process::Command::new("wsl.exe") + .args([ + "--distribution", + &self.distro, + "--exec", + "/bin/rm", + "-rf", + "--", + &self.path, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); } } } diff --git a/src-tauri/src/environment/wsl/operations/mod.rs b/src-tauri/src/environment/wsl/operations/mod.rs index 42b104b5..9f80d099 100644 --- a/src-tauri/src/environment/wsl/operations/mod.rs +++ b/src-tauri/src/environment/wsl/operations/mod.rs @@ -2,12 +2,11 @@ pub mod acquire; pub mod atomic_file; pub mod content_manifest; pub mod directory_count; +pub mod document; pub mod entry; -pub mod eve; pub mod inspection; -pub mod library_content; +pub mod library; pub mod materialize; -pub mod path; pub mod path_metadata; pub mod projection; pub mod projects; diff --git a/src-tauri/src/environment/wsl/operations/projects.rs b/src-tauri/src/environment/wsl/operations/projects.rs index 2fe7b210..c21c9bf8 100644 --- a/src-tauri/src/environment/wsl/operations/projects.rs +++ b/src-tauri/src/environment/wsl/operations/projects.rs @@ -1,51 +1,45 @@ -use tokio::time::Duration; - use crate::core::projects::{ProjectPathSemantics, ProjectsFile}; use crate::environment::path_mapping::{windows_storage_owner, WindowsStorageOwner}; use crate::environment::types::{ - EnvironmentRef, ProjectInfo, ProjectStorageInfo, RegisteredProject, ResourceLocator, - StorageAccess, -}; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, + EnvironmentRef, ProjectInfo, ProjectStorageInfo, RegisteredProject, StorageAccess, }; -use crate::environment::wsl::WslSession; +use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; -const PROJECT_STORAGE_SCRIPT: &str = include_str!("../scripts/projects.sh"); -const PROJECT_STORAGE_OPERATION: WslOperationDescriptor = - wsl_operation("projects", "project-storage", PROJECT_STORAGE_SCRIPT); +const PROJECT_STORAGE_DEADLINE_MILLIS: u64 = 10_000; + +pub struct ProjectsSnapshot { + pub projects: Vec, + pub revision: Option, + pub generation: u64, +} pub async fn project_infos( session: &WslSession, + workspace: &WslWorkspace, bindings: Vec, ) -> Result, AppError> { if bindings.is_empty() { return Ok(Vec::new()); } - let output = WslOperationExecutor::execute( - &PROJECT_STORAGE_OPERATION, - WslOperationRequest { - session: session.clone(), - args: bindings - .iter() - .map(|binding| binding.native_path.clone()) - .collect(), - stdin: Vec::new(), - timeout: Duration::from_secs(10), - stdout_limit: bindings.len().saturating_mul(16 * 1024).saturating_add(64), - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) - .await?; + let response: environment_protocol::MapWindowsPathsResponse = workspace + .request_worker_payload(environment_protocol::Message::MapPathsToWindows { + request: environment_protocol::MapWindowsPathsRequest { + paths: bindings + .iter() + .map(|binding| binding.native_path.clone()) + .collect(), + deadline_millis: PROJECT_STORAGE_DEADLINE_MILLIS, + }, + }) + .await?; let environment = EnvironmentRef::Wsl { distro_name: session.distro_name.clone(), }; - let storage = parse_project_storage(&environment, bindings.len(), &output.stdout)?; + if response.mapped.len() != bindings.len() { + return Err(protocol_error()); + } + let storage = project_storage_from_mapped(&environment, response.mapped)?; Ok(bindings .into_iter() .zip(storage) @@ -53,93 +47,98 @@ pub async fn project_infos( .collect()) } -pub fn parse_project_storage( +fn project_storage_from_mapped( environment: &EnvironmentRef, - project_count: usize, - bytes: &[u8], + mapped_paths: Vec>, ) -> Result, AppError> { let EnvironmentRef::Wsl { distro_name } = environment else { return Err(AppError::StaleEnvironment); }; - let mut fields = bytes.split(|byte| *byte == 0).collect::>(); - if fields.last().is_some_and(|field| field.is_empty()) { - fields.pop(); - } - if fields.first().copied() != Some(b"1".as_slice()) || fields.len() != 1 + project_count * 2 { - return Err(protocol_error()); - } - let (records, remainder) = fields[1..].as_chunks::<2>(); - debug_assert!(remainder.is_empty()); - records - .iter() - .map(|record| match record[0] { - b"error" => Ok(ProjectStorageInfo { + mapped_paths + .into_iter() + .map(|mapped| match mapped { + None => Ok(ProjectStorageInfo { access: StorageAccess::Unsupported, owner: None, }), - b"ok" => { - let mapped = std::str::from_utf8(record[1]).map_err(|_| protocol_error())?; - Ok(match windows_storage_owner(mapped) { - WindowsStorageOwner::Windows => ProjectStorageInfo { - access: StorageAccess::CrossStorage, - owner: Some(EnvironmentRef::Native), - }, - WindowsStorageOwner::Wsl { distro_name: owner } - if owner.eq_ignore_ascii_case(distro_name) => - { - ProjectStorageInfo { - access: StorageAccess::Native, - owner: Some(environment.clone()), - } + Some(mapped) => Ok(match windows_storage_owner(&mapped) { + WindowsStorageOwner::Windows => ProjectStorageInfo { + access: StorageAccess::CrossStorage, + owner: Some(EnvironmentRef::Native), + }, + WindowsStorageOwner::Wsl { distro_name: owner } + if owner.eq_ignore_ascii_case(distro_name) => + { + ProjectStorageInfo { + access: StorageAccess::Native, + owner: Some(environment.clone()), } - WindowsStorageOwner::Wsl { distro_name: owner } => ProjectStorageInfo { - access: StorageAccess::CrossStorage, - owner: Some(EnvironmentRef::Wsl { distro_name: owner }), - }, - WindowsStorageOwner::Unknown => ProjectStorageInfo { - access: StorageAccess::Unsupported, - owner: None, - }, - }) - } - _ => Err(protocol_error()), + } + WindowsStorageOwner::Wsl { distro_name: owner } => ProjectStorageInfo { + access: StorageAccess::CrossStorage, + owner: Some(EnvironmentRef::Wsl { distro_name: owner }), + }, + WindowsStorageOwner::Unknown => ProjectStorageInfo { + access: StorageAccess::Unsupported, + owner: None, + }, + }), }) .collect() } -pub async fn read_projects(session: &WslSession) -> Result, AppError> { - let target = projects_locator(session); - let io = WslAtomicDocumentIo::from_active_session(session.clone()); - match io.read_optional(&target).await? { - Some(bytes) => Ok(serde_json::from_slice::(&bytes)?.projects), - None => Ok(Vec::new()), - } +pub async fn read_projects( + session: &WslSession, + workspace: &WslWorkspace, +) -> Result, AppError> { + Ok(read_projects_snapshot(session, workspace).await?.projects) +} + +pub async fn read_projects_snapshot( + session: &WslSession, + workspace: &WslWorkspace, +) -> Result { + let snapshot = workspace + .read_optional_document_snapshot_once( + projects_path(session), + environment_protocol::MAX_DOCUMENT_BYTES, + ) + .await?; + let projects = match snapshot.bytes { + Some(bytes) => serde_json::from_slice::(&bytes)?.projects, + None => Vec::new(), + }; + Ok(ProjectsSnapshot { + projects, + revision: snapshot.revision, + generation: snapshot.generation, + }) } pub async fn write_projects( session: &WslSession, + workspace: &WslWorkspace, projects: Vec, -) -> Result, AppError> { + generation: u64, + expected_revision: Option, +) -> Result<(), AppError> { let file = ProjectsFile::new(projects, ProjectPathSemantics::Posix); - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic( - &projects_locator(session), + workspace + .write_document_atomic( + generation, + projects_path(session), + expected_revision, serde_json::to_vec_pretty(&file)?, ) .await?; - read_projects(session).await + Ok(()) } -fn projects_locator(session: &WslSession) -> ResourceLocator { - ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - native_path: format!( - "{}/.skill-deck/projects.json", - session.home.trim_end_matches('/') - ), - } +fn projects_path(session: &WslSession) -> String { + format!( + "{}/.skill-deck/projects.json", + session.home.trim_end_matches('/') + ) } fn protocol_error() -> AppError { @@ -157,10 +156,13 @@ mod tests { let environment = EnvironmentRef::Wsl { distro_name: "Ubuntu".to_string(), }; - let storage = parse_project_storage( + let storage = project_storage_from_mapped( &environment, - 3, - b"1\0ok\0C:\\Code\\app\0ok\0\\\\wsl.localhost\\Ubuntu\\home\\alice\\app\0error\0\0", + vec![ + Some(r"C:\Code\app".to_string()), + Some(r"\\wsl.localhost\Ubuntu\home\alice\app".to_string()), + None, + ], ) .unwrap(); assert_eq!(storage[0].access, StorageAccess::CrossStorage); diff --git a/src-tauri/src/environment/wsl/operations/recovery.rs b/src-tauri/src/environment/wsl/operations/recovery.rs index 1db8a26c..69fa8eba 100644 --- a/src-tauri/src/environment/wsl/operations/recovery.rs +++ b/src-tauri/src/environment/wsl/operations/recovery.rs @@ -1,239 +1,58 @@ -use tokio::time::Duration; +use sha2::{Digest, Sha256}; use crate::environment::recovery::{ - validate_recovery_marker, RecoveryFuture, RecoveryMarker, RecoveryMarkerLoad, - RecoveryMarkerRef, RecoveryMarkerStore, + validate_recovery_marker, RecoveryFuture, RecoveryMarker, RecoveryMarkerKind, + RecoveryMarkerLoad, RecoveryMarkerRef, RecoveryMarkerStore, }; -use crate::environment::types::{same_environment_identity, EnvironmentRef, ResourceLocator}; -use crate::environment::wsl::protocol::{ - wsl_operation, WslOperationDescriptor, WslOperationExecutor, WslOperationRequest, - DEFAULT_WSL_STDERR_LIMIT, DEFAULT_WSL_STDOUT_LIMIT, -}; -use crate::environment::wsl::{WslSession, WslWorkspace}; -use crate::error::{AppError, RecoveryResourceId}; - -const ENUMERATE_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const WRITE_MARKER_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const REMOVE_MARKER_SCRIPT: &str = include_str!("../scripts/recovery.sh"); - -const CLEANUP_RECOVERY_SCRIPT: &str = include_str!("../scripts/recovery.sh"); -const ENUMERATE_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "enumerate", ENUMERATE_SCRIPT); -const WRITE_MARKER_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "write-marker", WRITE_MARKER_SCRIPT); -const REMOVE_MARKER_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "remove-marker", REMOVE_MARKER_SCRIPT); -const CLEANUP_RECOVERY_OPERATION: WslOperationDescriptor = - wsl_operation("recovery", "cleanup", CLEANUP_RECOVERY_SCRIPT); - -pub fn parse_enumeration( - bytes: &[u8], - environment: EnvironmentRef, -) -> Result, AppError> { - let mut fields = bytes.split(|byte| *byte == 0); - if text(fields.next())? != "1" { - return Err(protocol_error()); - } - let mut loads = Vec::new(); - while let Some(tag) = fields.next() { - if tag.is_empty() { - continue; - } - if text(Some(tag))? != "R" { - return Err(protocol_error()); - } - let root = text(fields.next())?.to_string(); - let status = text(fields.next())?; - let content = fields.next().ok_or_else(protocol_error)?; - let managed_root = ResourceLocator { - environment: environment.clone(), - native_path: root.clone(), - }; - let parsed = (|| { - if status != "present" { - return Err(AppError::ConfigurationCorrupted { - message: format!("WSL recovery marker is {status}"), - }); - } - let marker: RecoveryMarker = serde_json::from_slice(content)?; - validate_recovery_marker(&marker)?; - let expected_id = root - .rsplit('/') - .next() - .and_then(|name| name.strip_prefix("skill-deck-operation-")) - .ok_or_else(protocol_error)?; - if !same_environment_identity(&marker.environment, &environment) - || marker.resource_id.as_str() != expected_id - { - return Err(AppError::ConfigurationCorrupted { - message: "WSL recovery marker does not match its managed root".to_string(), - }); - } - Ok(marker) - })(); - match parsed { - Ok(marker) => loads.push(RecoveryMarkerLoad::Valid { - marker_ref: RecoveryMarkerRef { - resource_id: marker.resource_id.clone(), - environment: environment.clone(), - managed_root, - }, - marker, - }), - Err(error) => loads.push(RecoveryMarkerLoad::Invalid { - managed_root, - error, - }), - } - } - Ok(loads) -} - -fn text(field: Option<&[u8]>) -> Result<&str, AppError> { - std::str::from_utf8(field.ok_or_else(protocol_error)?).map_err(|_| protocol_error()) -} - -fn protocol_error() -> AppError { - AppError::ConfigurationCorrupted { - message: "invalid WSL recovery enumeration response".to_string(), - } -} +use crate::environment::types::{EnvironmentRef, ResourceLocator}; +use crate::environment::wsl::WslWorkspace; +use crate::error::AppError; +#[derive(Clone)] pub struct WslRecoveryMarkerStore { - access: WslRecoveryAccess, - namespace: String, -} - -enum WslRecoveryAccess { - Workspace(WslWorkspace), - ActiveSession(WslSession), + workspace: WslWorkspace, } impl WslRecoveryMarkerStore { pub fn new(workspace: WslWorkspace) -> Self { - Self { - access: WslRecoveryAccess::Workspace(workspace), - namespace: "/tmp".to_string(), - } - } - - pub(crate) fn from_active_session(session: WslSession) -> Self { - Self { - access: WslRecoveryAccess::ActiveSession(session), - namespace: "/tmp".to_string(), - } - } - - fn distro_name(&self) -> &str { - match &self.access { - WslRecoveryAccess::Workspace(workspace) => workspace.distro_name(), - WslRecoveryAccess::ActiveSession(session) => &session.distro_name, - } + Self { workspace } } fn environment_ref(&self) -> EnvironmentRef { EnvironmentRef::Wsl { - distro_name: self.distro_name().to_string(), + distro_name: self.workspace.distro_name().to_string(), } } - fn root(&self, id: &RecoveryResourceId) -> String { - format!("{}/skill-deck-operation-{}", self.namespace, id.as_str()) - } - - fn marker_ref(&self, id: RecoveryResourceId) -> RecoveryMarkerRef { - let environment = self.environment_ref(); + fn marker_ref(&self, marker: &RecoveryMarker, managed_root: String) -> RecoveryMarkerRef { RecoveryMarkerRef { + resource_id: marker.resource_id.clone(), + environment: self.environment_ref(), managed_root: ResourceLocator { - environment: environment.clone(), - native_path: self.root(&id), + environment: self.environment_ref(), + native_path: managed_root, }, - resource_id: id, - environment, - } - } - - fn verify_ref(&self, marker_ref: &RecoveryMarkerRef) -> Result<(), AppError> { - let expected_environment = self.environment_ref(); - if !same_environment_identity(&marker_ref.environment, &expected_environment) - || !same_environment_identity( - &marker_ref.managed_root.environment, - &expected_environment, - ) - || marker_ref.managed_root.native_path != self.root(&marker_ref.resource_id) - { - return Err(AppError::UnsafePath { - path: marker_ref.managed_root.native_path.clone(), - reason: "WSL recovery root is outside the managed namespace".to_string(), - }); } - Ok(()) } - async fn run( - &self, - operation: &WslOperationDescriptor, - args: Vec, - stdin: Vec, - stdout_limit: usize, - ) -> Result, AppError> { - let execute = |session, args: Vec, stdin: Vec| async move { - WslOperationExecutor::execute( - operation, - WslOperationRequest { - session, - args, - stdin, - timeout: Duration::from_secs(30), - stdout_limit, - stderr_limit: DEFAULT_WSL_STDERR_LIMIT, - cancellation: None, - }, - ) + async fn list(&self) -> Result { + self.workspace + .request_worker_payload(environment_protocol::Message::ListMutationRecovery) .await - .map(|output| output.stdout) - }; - match &self.access { - WslRecoveryAccess::Workspace(workspace) => { - workspace - .with_session_retry(move |session| { - let args = args.clone(); - let stdin = stdin.clone(); - execute(session, args, stdin) - }) - .await - } - WslRecoveryAccess::ActiveSession(session) => { - execute(session.clone(), args, stdin).await - } - } } - async fn write( - &self, - marker: &RecoveryMarker, - mode: &str, - ) -> Result { - validate_recovery_marker(marker)?; - if !same_environment_identity(&marker.environment, &self.environment_ref()) { - return Err(AppError::StaleEnvironment); + fn validate_ref(&self, marker_ref: &RecoveryMarkerRef) -> Result<(), AppError> { + if marker_ref.environment != self.environment_ref() + || marker_ref.managed_root.environment != self.environment_ref() + || marker_ref.managed_root.native_path + != format!( + "/tmp/skill-deck-operation-{}", + marker_ref.resource_id.as_str() + ) + { + return Err(AppError::StaleTarget); } - let response = self - .run( - &WRITE_MARKER_OPERATION, - vec![ - self.namespace.clone(), - marker.resource_id.as_str().to_string(), - mode.to_string(), - ], - serde_json::to_vec(marker)?, - 32, - ) - .await?; - parse_write_response(&response)?; - Ok(self.marker_ref(marker.resource_id.clone())) + Ok(()) } } @@ -243,18 +62,10 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { } fn validate_managed_root(&self, root: &ResourceLocator) -> Result<(), AppError> { - let expected_environment = self.environment_ref(); - let Some(name) = root.native_path.strip_prefix("/tmp/skill-deck-operation-") else { - return Err(AppError::UnsafePath { - path: root.native_path.clone(), - reason: "WSL recovery root is outside the managed namespace".to_string(), - }); - }; - if !same_environment_identity(&root.environment, &expected_environment) - || name.is_empty() - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + if root.environment != self.environment_ref() + || !root.native_path.starts_with("/tmp/skill-deck-operation-") + || root.native_path.contains("/../") + || root.native_path.ends_with("/..") { return Err(AppError::UnsafePath { path: root.native_path.clone(), @@ -266,37 +77,85 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { fn create<'a>( &'a self, - marker: &'a RecoveryMarker, + _marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result> { - Box::pin(async move { self.write(marker, "create").await }) + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "hostCreatedWslMutationRecovery".to_string(), + path: None, + }) + }) } fn update<'a>( &'a self, - marker_ref: &'a RecoveryMarkerRef, - marker: &'a RecoveryMarker, + _marker_ref: &'a RecoveryMarkerRef, + _marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result<(), AppError>> { - Box::pin(async move { - self.verify_ref(marker_ref)?; - if marker.resource_id != marker_ref.resource_id { - return Err(AppError::StaleTarget); - } - self.write(marker, "update").await?; - Ok(()) + Box::pin(async { + Err(AppError::CapabilityUnavailable { + capability: "hostUpdatedWslMutationRecovery".to_string(), + path: None, + }) }) } fn enumerate<'a>(&'a self) -> RecoveryFuture<'a, Result, AppError>> { Box::pin(async move { - let response = self - .run( - &ENUMERATE_OPERATION, - vec![self.namespace.clone()], - Vec::new(), - DEFAULT_WSL_STDOUT_LIMIT, - ) - .await?; - parse_enumeration(&response, self.environment_ref()) + let response = self.list().await?; + Ok(response + .records + .into_iter() + .map(|record| { + let managed_root = ResourceLocator { + environment: self.environment_ref(), + native_path: record.managed_root.clone(), + }; + match record.state { + environment_protocol::MutationRecoveryState::Present => { + let parsed = + serde_json::from_slice::(&record.marker_bytes) + .map_err(AppError::from) + .and_then(|marker| { + validate_recovery_marker(&marker)?; + if marker.resource_id.as_str() != record.resource_id + || marker.environment != self.environment_ref() + { + return Err(AppError::StaleTarget); + } + Ok(marker) + }); + match parsed { + Ok(marker) => RecoveryMarkerLoad::Valid { + marker_ref: self.marker_ref(&marker, record.managed_root), + marker, + }, + Err(error) => RecoveryMarkerLoad::Invalid { + managed_root, + error, + }, + } + } + environment_protocol::MutationRecoveryState::Unreadable => { + RecoveryMarkerLoad::Invalid { + managed_root, + error: AppError::ConfigurationCorrupted { + message: "WSL recovery marker is unreadable".to_string(), + }, + } + } + environment_protocol::MutationRecoveryState::Unsafe => { + RecoveryMarkerLoad::Invalid { + managed_root, + error: AppError::UnsafePath { + path: record.managed_root, + reason: "WSL recovery root is unsafe".to_string(), + }, + } + } + } + }) + .collect()) }) } @@ -305,19 +164,40 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { marker_ref: &'a RecoveryMarkerRef, ) -> RecoveryFuture<'a, Result<(), AppError>> { Box::pin(async move { - self.verify_ref(marker_ref)?; - let response = self - .run( - &REMOVE_MARKER_OPERATION, - vec![ - self.namespace.clone(), - marker_ref.resource_id.as_str().to_string(), - ], - Vec::new(), - 32, + self.validate_ref(marker_ref)?; + let response = self.list().await?; + let record = response + .records + .into_iter() + .find(|record| record.resource_id == marker_ref.resource_id.as_str()) + .ok_or(AppError::StaleTarget)?; + let marker: RecoveryMarker = serde_json::from_slice(&record.marker_bytes)?; + validate_recovery_marker(&marker)?; + if marker.kind != RecoveryMarkerKind::CleanupOnly { + return Err(AppError::StaleTarget); + } + let cleanup = environment_protocol::MutationCleanupToken { + resource_id: record.resource_id, + marker_sha256: format!("sha256:{:x}", Sha256::digest(&record.marker_bytes)), + }; + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::AcknowledgeMutationUnit { + cleanup: cleanup.clone(), + }, + None, + std::time::Duration::from_secs(30), ) .await?; - parse_write_response(&response) + match response { + environment_protocol::Message::MutationAcknowledged { resource_id } + if resource_id == cleanup.resource_id => + { + Ok(()) + } + _ => Err(AppError::StaleTarget), + } }) } @@ -327,338 +207,49 @@ impl RecoveryMarkerStore for WslRecoveryMarkerStore { marker: &'a RecoveryMarker, ) -> RecoveryFuture<'a, Result<(), AppError>> { Box::pin(async move { - self.verify_ref(marker_ref)?; + self.validate_ref(marker_ref)?; validate_recovery_marker(marker)?; if marker.resource_id != marker_ref.resource_id - || !same_environment_identity(&marker.environment, &marker_ref.environment) - || marker.kind != crate::environment::recovery::RecoveryMarkerKind::CleanupOnly + || marker.kind != RecoveryMarkerKind::CleanupOnly { return Err(AppError::StaleTarget); } - let mut args = vec![ - self.namespace.clone(), - marker_ref.resource_id.as_str().to_string(), - ]; - args.extend( - marker - .entries - .iter() - .filter_map(|entry| entry.backup.as_ref()) - .map(|backup| backup.native_path.clone()), - ); - let response = self - .run( - &CLEANUP_RECOVERY_OPERATION, - args, - serde_json::to_vec(marker)?, - 32, + let response = self.list().await?; + let record = response + .records + .into_iter() + .find(|record| record.resource_id == marker.resource_id.as_str()) + .ok_or(AppError::StaleTarget)?; + let stored: RecoveryMarker = serde_json::from_slice(&record.marker_bytes)?; + if stored != *marker { + return Err(AppError::StaleTarget); + } + let backups = marker + .entries + .iter() + .filter_map(|entry| entry.backup.as_ref()) + .map(|backup| backup.native_path.clone()) + .collect(); + let (_, response) = self + .workspace + .request_worker_control_once( + environment_protocol::Message::CleanupMutationRecovery { + resource_id: marker.resource_id.as_str().to_string(), + expected_marker_json: record.marker_bytes, + backups, + }, + None, + std::time::Duration::from_secs(30), ) .await?; - parse_write_response(&response) + match response { + environment_protocol::Message::MutationRecoveryCleaned { resource_id } + if resource_id == marker.resource_id.as_str() => + { + Ok(()) + } + _ => Err(AppError::StaleTarget), + } }) } } - -fn parse_write_response(bytes: &[u8]) -> Result<(), AppError> { - (bytes == b"1\0").then_some(()).ok_or_else(protocol_error) -} - -#[cfg(test)] -mod capability_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::environment::wsl::{WslRuntime, WslSession}; - - fn session() -> WslSession { - WslSession { - distro_name: "Ubuntu".to_string(), - user: "alice".to_string(), - uid: 1000, - home: "/home/alice".to_string(), - xdg_state_home: None, - config_home: "/home/alice/.config".to_string(), - environment: BTreeMap::new(), - runtime_generation: 0, - } - } - - #[tokio::test] - async fn recovery_store_from_an_old_cycle_cannot_enumerate_after_reenable() { - let runtime = WslRuntime::default(); - runtime.insert(session()); - let store = - WslRecoveryMarkerStore::new(runtime.workspace("Ubuntu").expect("enabled workspace")); - - let disable = runtime.begin_disable().expect("begin disable"); - disable - .wait_for_quiescence(Duration::from_secs(1)) - .await - .expect("quiescent runtime"); - disable.commit_disabled(); - runtime - .begin_enable() - .expect("begin enable") - .commit_enabled(); - - let error = store.enumerate().await.expect_err("stale recovery store"); - - assert!(matches!(error, AppError::EnvironmentUnavailable { .. })); - } -} - -#[cfg(all(test, target_os = "linux"))] -#[allow( - clippy::disallowed_methods, - reason = "恢复协议测试需要直接运行待验证的 shell 测试脚本" -)] -mod tests { - use std::fs; - use std::io::Write; - use std::process::{Command, Stdio}; - - use tempfile::tempdir; - - use super::*; - use crate::environment::recovery::{ - RecoveryEntryPhase, RecoveryMarker, RecoveryMarkerEntry, RecoveryMarkerKind, - RecoveryMarkerLoad, RECOVERY_MARKER_SCHEMA_VERSION, - }; - use crate::environment::types::{EnvironmentRef, ResourceLocator}; - use crate::error::RecoveryResourceId; - - fn marker(environment: EnvironmentRef, id: &str) -> RecoveryMarker { - RecoveryMarker { - schema_version: RECOVERY_MARKER_SCHEMA_VERSION, - resource_id: RecoveryResourceId::parse(id).unwrap(), - kind: RecoveryMarkerKind::RecoveryRequired, - environment: environment.clone(), - operation_id: "operation-1".to_string(), - unit_id: "unit-1".to_string(), - subject: Some(crate::environment::recovery::RecoverySubject { - operation_kind: crate::core::mutation::MutationKind::Install, - skill_name: "demo".to_string(), - context: crate::environment::types::SkillLocationRef { - environment: environment.clone(), - scope: crate::environment::types::SkillLocation::Global, - }, - }), - created_at_epoch_ms: 1, - entries: vec![RecoveryMarkerEntry { - physical_target_digest: "target-1".to_string(), - destination: ResourceLocator { - environment: environment.clone(), - native_path: "/home/alice/.agents/skills/demo".to_string(), - }, - backup: Some(ResourceLocator { - environment, - native_path: "/home/alice/.agents/skills/.skill-deck-backup-demo".to_string(), - }), - expected_state: crate::environment::recovery::RecoveryExpectedEntryState::Present, - original_fingerprint: "entry-v1-original".to_string(), - phase: RecoveryEntryPhase::RestoreFailed, - }], - } - } - - #[test] - fn restart_enumeration_keeps_valid_marker_and_isolates_invalid_sibling() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let valid_root = temp.path().join("skill-deck-operation-valid-id"); - let invalid_root = temp.path().join("skill-deck-operation-invalid-id"); - fs::create_dir(&valid_root).unwrap(); - fs::create_dir(&invalid_root).unwrap(); - fs::write( - valid_root.join("recovery.json"), - serde_json::to_vec(&marker(environment.clone(), "valid-id")).unwrap(), - ) - .unwrap(); - fs::write(invalid_root.join("recovery.json"), b"{broken").unwrap(); - - let output = Command::new("/bin/sh") - .arg("-c") - .arg(ENUMERATE_SCRIPT) - .arg("--") - .arg("enumerate") - .arg(temp.path()) - .output() - .unwrap(); - assert!(output.status.success()); - let loads = parse_enumeration(&output.stdout, environment).unwrap(); - - assert_eq!(loads.len(), 2); - assert_eq!( - loads - .iter() - .filter(|load| matches!(load, RecoveryMarkerLoad::Valid { .. })) - .count(), - 1 - ); - assert_eq!( - loads - .iter() - .filter(|load| matches!(load, RecoveryMarkerLoad::Invalid { .. })) - .count(), - 1 - ); - } - - #[test] - fn marker_write_requires_matching_operation_owner_and_updates_atomically() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let root = temp.path().join("skill-deck-operation-owned-id"); - let mut initial = marker(environment, "owned-id"); - - let output = run_write(temp.path(), "owned-id", "create", &initial); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - initial - ); - - initial.kind = RecoveryMarkerKind::CleanupOnly; - assert!(run_write(temp.path(), "owned-id", "update", &initial) - .status - .success()); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - initial - ); - - fs::write(root.join(".skill-deck-owner"), b"1\nother-id\n").unwrap(); - assert!(!run_write(temp.path(), "owned-id", "update", &initial) - .status - .success()); - } - - #[test] - fn marker_create_initializes_the_owned_operation_root() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let value = marker(environment, "prepared-id"); - - let output = run_write(temp.path(), "prepared-id", "create", &value); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let root = temp.path().join("skill-deck-operation-prepared-id"); - assert_eq!( - fs::read(root.join(".skill-deck-owner")).unwrap(), - b"1\nprepared-id\n" - ); - assert_eq!( - serde_json::from_slice::( - &fs::read(root.join("recovery.json")).unwrap() - ) - .unwrap(), - value - ); - } - - #[test] - fn confirmed_cleanup_removes_owned_backups_and_the_managed_marker_root() { - let temp = tempdir().unwrap(); - let environment = EnvironmentRef::Wsl { - distro_name: "Ubuntu".to_string(), - }; - let root = temp.path().join("skill-deck-operation-cleanup-id"); - fs::create_dir(&root).unwrap(); - fs::write(root.join(".skill-deck-owner"), b"1\ncleanup-id\n").unwrap(); - let backup = temp.path().join("targets/.skill-deck-backup-demo"); - fs::create_dir_all(&backup).unwrap(); - fs::write(backup.join("SKILL.md"), b"backup").unwrap(); - let destination = temp.path().join("targets/demo"); - let mut value = marker(environment, "cleanup-id"); - value.kind = RecoveryMarkerKind::CleanupOnly; - value.entries[0].destination.native_path = destination.to_string_lossy().into_owned(); - value.entries[0].backup = Some(ResourceLocator { - environment: value.environment.clone(), - native_path: backup.to_string_lossy().into_owned(), - }); - fs::write( - root.join("recovery.json"), - serde_json::to_vec(&value).unwrap(), - ) - .unwrap(); - - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(CLEANUP_RECOVERY_SCRIPT) - .arg("--") - .arg("cleanup") - .arg(temp.path()) - .arg("cleanup-id") - .arg(&backup) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&serde_json::to_vec(&value).unwrap()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!(output.stdout, b"1\0"); - assert!(!backup.exists()); - assert!(!root.exists()); - } - - fn run_write( - namespace: &std::path::Path, - id: &str, - mode: &str, - marker: &RecoveryMarker, - ) -> std::process::Output { - let mut child = Command::new("/bin/sh") - .arg("-c") - .arg(WRITE_MARKER_SCRIPT) - .arg("--") - .arg("write-marker") - .arg(namespace) - .arg(id) - .arg(mode) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(&serde_json::to_vec(marker).unwrap()) - .unwrap(); - child.wait_with_output().unwrap() - } -} diff --git a/src-tauri/src/environment/wsl/scripts/atomic-file.sh b/src-tauri/src/environment/wsl/scripts/atomic-file.sh deleted file mode 100644 index 793e6320..00000000 --- a/src-tauri/src/environment/wsl/scripts/atomic-file.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - read) - if [ -f "$1" ]; then printf '1\0'; printf '1\0'; cat -- "$1"; elif [ ! -e "$1" ]; then printf '1\0'; printf '0\0'; else exit 66; fi - ;; - write) - - path=$1 - dir=${path%/*} - [ "$dir" != "$path" ] || dir=. - mkdir -p -- "$dir" || exit 67 - tmp=$(mktemp "$dir/.skill-deck-document.XXXXXX") || exit 68 - trap 'rm -f -- "$tmp"' EXIT HUP INT TERM - cat > "$tmp" || exit 69 - sync "$tmp" 2>/dev/null || exit 70 - rm -f -- "$path.bak" || exit 71 - sync "$dir" 2>/dev/null || exit 72 - mv -f -- "$tmp" "$path" || exit 73 - tmp= - sync "$path" 2>/dev/null || exit 74 - sync "$dir" 2>/dev/null || exit 75 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/library-content.sh b/src-tauri/src/environment/wsl/scripts/library-content.sh deleted file mode 100644 index 3500e7a2..00000000 --- a/src-tauri/src/environment/wsl/scripts/library-content.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/bin/sh -set -eu - -subcommand=$1 -shift -root=${HOME%/}/.skill-deck/skill-libraries -transactions=$root/.transactions - -write_state() { - target=$1 - value=$2 - temporary=$target.tmp.$$ - printf '%s' "$value" > "$temporary" - mv -f -- "$temporary" "$target" -} - -catalog_hash() { - if [ -f "$root/catalog.json" ]; then - sha256sum -- "$root/catalog.json" | awk '{ print $1 }' - fi -} - -rollback_content() { - destination=$1 - backup=$2 - rm -rf -- "$destination" - if [ -d "$backup" ]; then - mv -- "$backup" "$destination" || exit 72 - fi -} - -recover_transactions() { - [ -d "$transactions" ] || return 0 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/destination" ] && [ -f "$transaction/phase" ] || exit 75 - destination=$(cat -- "$transaction/destination") - phase=$(cat -- "$transaction/phase") - [ -f "$transaction/desired-presence" ] || exit 75 - desired_presence=$(cat -- "$transaction/desired-presence") - stage=$transaction/stage - backup=$transaction/backup - case "$phase" in - preparing) - ;; - staged) - ;; - backedUp) - if [ ! -e "$destination" ] && [ -d "$backup" ]; then - mv -- "$backup" "$destination" || exit 72 - elif [ -e "$destination" ] && [ ! -e "$backup" ]; then - : - elif [ -e "$destination" ] && [ -d "$backup" ] && [ ! -e "$stage" ]; then - : - else - exit 73 - fi - ;; - activated) - rollback_content "$destination" "$backup" - ;; - catalogPrepared) - if [ "$desired_presence" = 1 ]; then - [ -d "$destination" ] || exit 74 - else - [ ! -e "$destination" ] || exit 74 - fi - [ -f "$transaction/expected-catalog-hash" ] || exit 75 - expected=$(cat -- "$transaction/expected-catalog-hash") - current=$(catalog_hash) - if [ "$current" != "$expected" ]; then - rollback_content "$destination" "$backup" - fi - ;; - catalogCommitted) - if [ "$desired_presence" = 1 ]; then - [ -d "$destination" ] || exit 74 - else - [ ! -e "$destination" ] || exit 74 - fi - ;; - *) exit 75 ;; - esac - rm -rf -- "$stage" "$backup" - rm -rf -- "$transaction" - done -} - -mkdir -p -- "$root" "$transactions" -case "$subcommand" in - prepare-catalog|finalize-catalog) ;; - *) recover_transactions ;; -esac - -case "$subcommand" in - recover) - printf '1\0' - ;; - ensure-libraries) - for library_id in "$@"; do - mkdir -p -- "$root/libraries/$library_id/skills" - done - printf '1\0' - ;; - replace) - library_id=$1 - skill_name=$2 - operation_id=$3 - destination=$root/libraries/$library_id/skills/$skill_name - transaction=$transactions/$operation_id - stage=$transaction/stage - backup=$transaction/backup - mkdir -p -- "$transaction" "${destination%/*}" - write_state "$transaction/destination" "$destination" - write_state "$transaction/desired-presence" '1' - write_state "$transaction/phase" 'preparing' - tar -xf - -C "$transaction" --no-same-owner || exit 76 - [ -d "$stage" ] || exit 77 - [ -f "$stage/SKILL.md" ] || exit 78 - write_state "$transaction/phase" 'staged' - if [ -e "$destination" ]; then - write_state "$transaction/phase" 'backedUp' - mv -- "$destination" "$backup" || exit 79 - fi - write_state "$transaction/phase" 'activated' - mv -- "$stage" "$destination" || exit 80 - [ -f "$destination/SKILL.md" ] || exit 81 - printf '1\0' - ;; - delete) - library_id=$1 - skill_name=$2 - operation_id=$3 - destination=$root/libraries/$library_id/skills/$skill_name - transaction=$transactions/$operation_id - backup=$transaction/backup - [ -e "$destination" ] || exit 85 - mkdir -p -- "$transaction" - write_state "$transaction/destination" "$destination" - write_state "$transaction/desired-presence" '0' - write_state "$transaction/phase" 'preparing' - write_state "$transaction/phase" 'backedUp' - mv -- "$destination" "$backup" || exit 79 - write_state "$transaction/phase" 'activated' - printf '1\0' - ;; - prepare-catalog) - expected=$1 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/phase" ] || continue - if [ "$(cat -- "$transaction/phase")" = 'activated' ]; then - write_state "$transaction/expected-catalog-hash" "$expected" - write_state "$transaction/phase" 'catalogPrepared' - fi - done - printf '1\0' - ;; - finalize-catalog) - expected=$1 - current=$(catalog_hash) - [ "$current" = "$expected" ] || exit 89 - for transaction in "$transactions"/*; do - [ -d "$transaction" ] || continue - [ -f "$transaction/phase" ] || continue - if [ "$(cat -- "$transaction/phase")" = 'catalogPrepared' ]; then - [ -f "$transaction/expected-catalog-hash" ] || exit 90 - [ "$(cat -- "$transaction/expected-catalog-hash")" = "$expected" ] || exit 91 - write_state "$transaction/phase" 'catalogCommitted' - rm -rf -- "$transaction/backup" "$transaction" - fi - done - printf '1\0' - ;; - remove-library) - library_id=$1 - destination=$root/libraries/$library_id - if [ -e "$destination" ]; then - rm -rf -- "$destination" - fi - printf '1\0' - ;; - remove-application) - project_id=$1 - rm -f -- "$root/applications/projects/$project_id.json" - printf '1\0' - ;; - *) exit 64 ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/materialize.sh b/src-tauri/src/environment/wsl/scripts/materialize.sh deleted file mode 100644 index 71814307..00000000 --- a/src-tauri/src/environment/wsl/scripts/materialize.sh +++ /dev/null @@ -1,749 +0,0 @@ -#!/bin/sh - -fingerprint_content() { - root=$1 - [ -d "$root" ] && [ ! -L "$root" ] || return 1 - { - printf 'skill-deck-wsl-content-v1\0' - # 变量需要在逐项执行的子 shell 中展开,不能由外层 shell 提前替换。 - # shellcheck disable=SC2016 - LC_ALL=C find "$root" -mindepth 1 -print0 | LC_ALL=C sort -z | xargs -0 -r -n1 /bin/sh -c ' - root=$1 - path=$2 - relative=${path#"$root"/} - [ "$relative" != "$path" ] || exit 1 - if [ -L "$path" ]; then - printf "l\0%s\0" "$relative" - readlink -n -- "$path" || exit 1 - printf "\0" - elif [ -d "$path" ]; then - printf "d\0%s\0" "$relative" - elif [ -f "$path" ]; then - digest_line=$(sha256sum -- "$path") || exit 1 - digest_line=${digest_line#\\} - mode=$(stat -Lc %a -- "$path") || exit 1 - executable=0 - case "$mode" in *[1357]*) executable=1 ;; esac - printf "f\0%s\0%s\0%s\0" "$relative" "${digest_line%% *}" "$executable" - else - exit 1 - fi - ' sh "$root" - } | sha256sum | { read -r digest _; printf %s "$digest"; } -} - -subcommand=$1 -shift -case "$subcommand" in - stage) - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || exit 62 - owner=$operation_root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 63 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 64 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 65 - [ "$(sed -n '2p' "$owner")" = "$operation_id" ] || exit 66 - [ -f "$operation_root/recovery.json" ] && [ ! -L "$operation_root/recovery.json" ] || exit 67 - request_root=$operation_root/request - [ ! -e "$request_root" ] && [ ! -L "$request_root" ] || exit 68 - umask 077 - mkdir -- "$request_root" || exit 69 - - cleanup_stage() { - for entry_root in "$operation_root"/entry-*; do - if [ ! -d "$entry_root" ] || [ -L "$entry_root" ]; then continue; fi - if [ -f "$entry_root/destination" ] && [ ! -L "$entry_root/destination" ]; then - destination=$(cat -- "$entry_root/destination") || destination= - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - rm -rf -- "$parent/.skill-deck-probe-$operation_id-"* "$parent/.skill-deck-probe-renamed-$operation_id-"* - fi - if [ -f "$entry_root/stage" ] && [ ! -L "$entry_root/stage" ]; then - stage_path=$(cat -- "$entry_root/stage") || stage_path= - if [ -n "$stage_path" ]; then - name=${stage_path##*/} - case "$name" in .skill-deck-stage-$operation_id-*) rm -rf -- "$stage_path" ;; esac - fi - fi - rm -rf -- "$entry_root" - done - rm -rf -- "$request_root" - } - trap cleanup_stage EXIT HUP INT TERM - export request_root - - # shellcheck disable=SC2016 - if ! xargs -0 -r -n7 /bin/sh -c ' - [ "$#" -eq 7 ] || exit 255 - tag=$1 - second=$2 - third=$3 - fourth=$4 - fifth=$5 - sixth=$6 - seventh=$7 - case "$tag" in - H) - [ "$second" = 1 ] || exit 255 - for value in "$third" "$fourth"; do - case "$value" in ""|*[!0-9]*) exit 255 ;; esac - done - [ -z "$fifth" ] && [ -z "$sixth" ] && [ -z "$seventh" ] || exit 255 - [ ! -e "$request_root/header" ] || exit 255 - printf "%s\n%s\n" "$third" "$fourth" > "$request_root/header" || exit 255 - ;; - E) - [ -f "$request_root/header" ] || exit 255 - case "$second" in ??????) ;; *) exit 255 ;; esac - case "$second" in *[!0-9]*) exit 255 ;; esac - case "$third" in /*) ;; *) exit 255 ;; esac - case "$fourth" in keep|materialize|symlink|remove) ;; *) exit 255 ;; esac - case "$sixth" in entry-v1-*) ;; *) exit 255 ;; esac - case "$seventh" in ""|*[!0-9]*) exit 255 ;; esac - case "$fourth" in - keep|remove) [ -z "$fifth" ] && [ "$seventh" = 0 ] || exit 255 ;; - symlink) case "$fifth" in ""|/*|*\\*) exit 255 ;; esac; [ "$seventh" = 0 ] || exit 255 ;; - materialize) case "$fifth" in /*) ;; *) exit 255 ;; esac ;; - esac - entry=$request_root/entry-$second - [ ! -e "$entry" ] && mkdir -- "$entry" || exit 255 - printf %s "$third" > "$entry/destination" || exit 255 - printf %s "$fourth" > "$entry/action" || exit 255 - printf %s "$fifth" > "$entry/source" || exit 255 - printf %s "$sixth" > "$entry/expected" || exit 255 - printf %s "$seventh" > "$entry/manifest-count" || exit 255 - : > "$entry/manifest" - : > "$entry/manifest-seen" - ;; - M) - [ -f "$request_root/header" ] || exit 255 - entry=$request_root/entry-$second - [ -d "$entry" ] && [ ! -L "$entry" ] || exit 255 - case "$third" in directory|file) ;; *) exit 255 ;; esac - case "$fourth" in ""|/*|*\\*|..|../*|*/..|*/../*|.|./*|*/.|*/./*|*//* ) exit 255 ;; esac - case "$third" in - directory) [ -z "$fifth" ] && [ "$sixth" = 0 ] && [ "$seventh" = 0 ] || exit 255 ;; - file) - [ "${#fifth}" -eq 64 ] || exit 255 - case "$fifth" in *[!0-9a-f]*) exit 255 ;; esac - case "$sixth" in 0|1) ;; *) exit 255 ;; esac - case "$seventh" in ""|*[!0-9]*) exit 255 ;; esac - ;; - esac - manifest_key=$(printf %s "$fourth" | sha256sum) || exit 255 - manifest_key=${manifest_key%% *} - [ ! -e "$entry/manifest-key-$manifest_key" ] || exit 255 - : > "$entry/manifest-key-$manifest_key" || exit 255 - printf "%s\0%s\0%s\0%s\0%s\0" "$third" "$fourth" "$fifth" "$sixth" "$seventh" >> "$entry/manifest" || exit 255 - printf "1\n" >> "$entry/manifest-seen" || exit 255 - ;; - *) exit 255 ;; - esac - printf "1\n" >> "$request_root/records-seen" || exit 255 - ' sh; then - exit 70 - fi - - [ -f "$request_root/header" ] || exit 71 - expected_records=$(sed -n '1p' "$request_root/header") || exit 72 - expected_entries=$(sed -n '2p' "$request_root/header") || exit 73 - actual_records=$(wc -l < "$request_root/records-seen") || exit 74 - [ "$actual_records" = "$expected_records" ] || exit 75 - actual_entries=$(find "$request_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | wc -l) || exit 76 - [ "$actual_entries" = "$expected_entries" ] || exit 77 - - entry_number=0 - while [ "$entry_number" -lt "$expected_entries" ]; do - entry_index=$(printf '%06d' "$entry_number") || exit 78 - request_entry=$request_root/entry-$entry_index - [ -d "$request_entry" ] && [ ! -L "$request_entry" ] || exit 79 - expected_manifest=$(cat -- "$request_entry/manifest-count") || exit 80 - actual_manifest=$(wc -l < "$request_entry/manifest-seen") || exit 81 - [ "$actual_manifest" = "$expected_manifest" ] || exit 82 - - destination=$(cat -- "$request_entry/destination") || exit 83 - action=$(cat -- "$request_entry/action") || exit 84 - source=$(cat -- "$request_entry/source") || exit 85 - expected=$(cat -- "$request_entry/expected") || exit 86 - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - mkdir -p -- "$parent" || exit 87 - [ -d "$parent" ] && [ ! -L "$parent" ] || exit 88 - parent_identity=$(stat -Lc '%d:%i' -- "$parent") || exit 89 - entry_root=$operation_root/entry-$entry_index - [ ! -e "$entry_root" ] && [ ! -L "$entry_root" ] || exit 90 - mkdir -- "$entry_root" || exit 91 - stage_path=$parent/.skill-deck-stage-$operation_id-$entry_index - backup=$parent/.skill-deck-backup-$operation_id-$entry_index - [ ! -e "$stage_path" ] && [ ! -L "$stage_path" ] || exit 92 - [ ! -e "$backup" ] && [ ! -L "$backup" ] || exit 93 - printf %s "$destination" > "$entry_root/destination" || exit 94 - printf %s "$action" > "$entry_root/action" || exit 95 - printf %s "$expected" > "$entry_root/expected" || exit 96 - printf %s "$stage_path" > "$entry_root/stage" || exit 97 - printf %s "$backup" > "$entry_root/backup" || exit 98 - printf %s "$parent_identity" > "$entry_root/parent-identity" || exit 99 - printf %s "$source" > "$entry_root/source" || exit 100 - printf %s "$expected_manifest" > "$entry_root/manifest-count" || exit 101 - cp -- "$request_entry/manifest" "$entry_root/manifest" || exit 102 - if [ -d "$destination" ] && [ ! -L "$destination" ]; then - fingerprint_content "$destination" > "$entry_root/expected-content" || exit 122 - fi - - case "$action" in - keep|remove) - printf %s '' > "$entry_root/stage" || exit 103 - ;; - symlink) - ln -s -- "$source" "$stage_path" || exit 104 - [ -L "$stage_path" ] || exit 105 - [ "$(readlink -- "$stage_path")" = "$source" ] || exit 106 - ;; - materialize) - [ -d "$source/blobs" ] && [ ! -L "$source" ] && [ ! -L "$source/blobs" ] || exit 107 - mkdir -- "$stage_path" || exit 108 - export stage_path source - # shellcheck disable=SC2016 - if ! xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) mkdir -p -- "$path" || exit 255 ;; - file) - blob=$source/blobs/$blob_id - [ -f "$blob" ] && [ ! -L "$blob" ] || exit 255 - [ "$(wc -c < "$blob")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$blob") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - mkdir -p -- "${path%/*}" || exit 255 - cp -- "$blob" "$path" || exit 255 - if [ "$executable" = 1 ]; then chmod +x -- "$path"; else chmod -x -- "$path"; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$request_entry/manifest"; then - exit 109 - fi - export stage_path - # shellcheck disable=SC2016 - if ! xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$request_entry/manifest"; then - exit 110 - fi - actual_stage_entries=$(find "$stage_path" -mindepth 1 -printf . | wc -c) || exit 111 - [ "$actual_stage_entries" = "$expected_manifest" ] || exit 112 - ;; - *) exit 113 ;; - esac - - probe=$parent/.skill-deck-probe-$operation_id-$entry_index - renamed_probe=$parent/.skill-deck-probe-renamed-$operation_id-$entry_index - [ ! -e "$probe" ] && [ ! -L "$probe" ] || exit 114 - [ ! -e "$renamed_probe" ] && [ ! -L "$renamed_probe" ] || exit 115 - mkdir -- "$probe" || exit 116 - printf 'stage-preflight-v1\n' > "$probe/.skill-deck-owner" || exit 117 - mv -- "$probe" "$renamed_probe" || { rm -rf -- "$probe" "$renamed_probe"; exit 118; } - mv -- "$renamed_probe" "$probe" || { rm -rf -- "$probe" "$renamed_probe"; exit 119; } - rm -rf -- "$probe" || exit 120 - entry_number=$((entry_number + 1)) - done - - rm -rf -- "$request_root" || exit 121 - printf '1\0' - trap - EXIT HUP INT TERM - ;; - swap) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - validate_stage() { - entry_root=$1 - action=$(cat -- "$entry_root/action") || return 1 - stage_path=$(cat -- "$entry_root/stage") || return 1 - case "$action" in - keep|remove) [ -z "$stage_path" ] || return 1 ;; - symlink) - expected_target=$(cat -- "$entry_root/source") || return 1 - case "$expected_target" in ""|/*|*\\*) return 1 ;; esac - [ -L "$stage_path" ] || return 1 - [ "$(readlink -- "$stage_path")" = "$expected_target" ] || return 1 - ;; - materialize) - [ -d "$stage_path" ] && [ ! -L "$stage_path" ] || return 1 - export stage_path - # shellcheck disable=SC2016 - xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$stage_path/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$entry_root/manifest" || return 1 - expected_count=$(cat -- "$entry_root/manifest-count") || return 1 - actual_count=$(find "$stage_path" -mindepth 1 -printf . | wc -c) || return 1 - [ "$actual_count" = "$expected_count" ] || return 1 - ;; - *) return 1 ;; - esac - } - - preflight_atomic_replace() { - parent=$1 - entry_name=$2 - probe=$parent/.skill-deck-probe-$operation_id-$entry_name - renamed_probe=$parent/.skill-deck-probe-renamed-$operation_id-$entry_name - [ ! -e "$probe" ] && [ ! -L "$probe" ] || return 1 - [ ! -e "$renamed_probe" ] && [ ! -L "$renamed_probe" ] || return 1 - mkdir -- "$probe" || return 1 - printf 'stage-preflight-v1\n' > "$probe/.skill-deck-owner" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - mv -- "$probe" "$renamed_probe" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - mv -- "$renamed_probe" "$probe" || { - rm -rf -- "$probe" "$renamed_probe" - return 1 - } - rm -rf -- "$probe" || return 1 - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - entries=$(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort) - [ -n "$entries" ] || exit 62 - for entry_root in $entries; do - destination=$(cat -- "$entry_root/destination") || exit 63 - expected=$(cat -- "$entry_root/expected") || exit 64 - expected_parent=$(cat -- "$entry_root/parent-identity") || exit 65 - backup=$(cat -- "$entry_root/backup") || exit 66 - parent=${destination%/*} - [ -n "$parent" ] || parent=/ - actual_parent=$(stat -Lc '%d:%i' -- "$parent") || exit 67 - [ "$actual_parent" = "$expected_parent" ] || exit 68 - actual=$(fingerprint_entry "$destination") || exit 69 - [ "$actual" = "$expected" ] || exit 70 - if [ -f "$entry_root/expected-content" ] && [ ! -L "$entry_root/expected-content" ]; then - expected_content=$(cat -- "$entry_root/expected-content") || exit 81 - actual_content=$(fingerprint_content "$destination") || exit 82 - [ "$actual_content" = "$expected_content" ] || exit 83 - fi - [ ! -e "$backup" ] && [ ! -L "$backup" ] || exit 71 - validate_stage "$entry_root" || exit 72 - preflight_atomic_replace "$parent" "${entry_root##*/}" || exit 73 - done - for entry_root in $entries; do - destination=$(cat -- "$entry_root/destination") || { restore_all; exit 74; } - stage=$(cat -- "$entry_root/stage") || { restore_all; exit 75; } - backup=$(cat -- "$entry_root/backup") || { restore_all; exit 76; } - action=$(cat -- "$entry_root/action") || { restore_all; exit 77; } - [ "$action" = keep ] && continue - if [ -e "$backup" ] || [ -L "$backup" ]; then - restore_all - exit 78 - fi - if [ -e "$destination" ] || [ -L "$destination" ]; then - mv -- "$destination" "$backup" || { restore_all; exit 71; } - : > "$entry_root/backed-up" - fi - if [ "$action" != remove ]; then - mv -- "$stage" "$destination" || { restore_all || exit 90; exit 72; } - : > "$entry_root/installed" - fi - done - printf '1\0' - - ;; - verify) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - verify_materialized_destination() { - entry_root=$1 - destination=$2 - export destination - # shellcheck disable=SC2016 - xargs -0 -r -n5 /bin/sh -c ' - [ "$#" -eq 5 ] || exit 255 - kind=$1 - relative=$2 - blob_id=$3 - executable=$4 - expected_size=$5 - path=$destination/$relative - case "$kind" in - directory) [ -d "$path" ] && [ ! -L "$path" ] || exit 255 ;; - file) - [ -f "$path" ] && [ ! -L "$path" ] || exit 255 - [ "$(wc -c < "$path")" = "$expected_size" ] || exit 255 - digest_line=$(sha256sum -- "$path") || exit 255 - [ "${digest_line%% *}" = "$blob_id" ] || exit 255 - if [ "$executable" = 1 ]; then [ -x "$path" ]; else [ ! -x "$path" ]; fi || exit 255 - ;; - *) exit 255 ;; - esac - ' sh < "$entry_root/manifest" || return 1 - expected_count=$(cat -- "$entry_root/manifest-count") || return 1 - actual_count=$(find "$destination" -mindepth 1 -printf . | wc -c) || return 1 - [ "$actual_count" = "$expected_count" ] - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort); do - destination=$(cat -- "$entry_root/destination") || exit 62 - action=$(cat -- "$entry_root/action") || exit 63 - case "$action" in - keep) - expected=$(cat -- "$entry_root/expected") || exit 64 - actual=$(fingerprint_entry "$destination") || exit 65 - [ "$actual" = "$expected" ] || exit 66 - ;; - materialize) - [ -d "$destination" ] && [ ! -L "$destination" ] || exit 64 - verify_materialized_destination "$entry_root" "$destination" || exit 65 - ;; - symlink) - [ -L "$destination" ] || exit 66 - source=$(cat -- "$entry_root/source") || exit 67 - [ "$(readlink -- "$destination")" = "$source" ] || exit 68 - ;; - remove) [ ! -e "$destination" ] && [ ! -L "$destination" ] || exit 69 ;; - *) exit 70 ;; - esac - done - printf '1\0' - - ;; - restore) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - restore_all || exit 90 - printf '1\0' - - ;; - cleanup) - - validate_operation() { - operation_root=$1 - operation_id=$2 - case "$operation_id" in ''|*[!A-Za-z0-9_-]*) return 1 ;; esac - [ -d "$operation_root" ] && [ ! -L "$operation_root" ] || return 1 - marker=$operation_root/.skill-deck-owner - [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 - [ "$(wc -l < "$marker")" -eq 2 ] || return 1 - [ "$(sed -n '1p' "$marker")" = 1 ] || return 1 - [ "$(sed -n '2p' "$marker")" = "$operation_id" ] || return 1 - } - - fingerprint_entry() { - path=$1 - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - printf %s entry-v1-missing - return 0 - fi - link_target= - if [ -L "$path" ]; then link_target=$(readlink -- "$path") || return 1; fi - device=$(stat -c %d -- "$path") || return 1 - inode=$(stat -c %i -- "$path") || return 1 - mode=$(stat -c %f -- "$path") || return 1 - size=$(stat -c %s -- "$path") || return 1 - mtime_seconds=$(stat -c %Y -- "$path") || return 1 - mtime_text=$(stat -c %y -- "$path") || return 1 - case "$mtime_text" in - *.*) mtime_nanos=${mtime_text#*.}; mtime_nanos=${mtime_nanos%% *} ;; - *) mtime_nanos=0 ;; - esac - digest_line=$({ - printf 'skill-deck-wsl-entry-v1\0' - for value in "$device" "$inode" "$mode" "$size" "$mtime_seconds" "$mtime_nanos"; do - printf '%s\0' "$value" - done - printf %s "$link_target" - } | sha256sum) || return 1 - printf 'entry-v1-%s' "${digest_line%% *}" - } - - remove_no_follow() { - path=$1 - if [ -L "$path" ] || [ -f "$path" ]; then rm -f -- "$path" - elif [ -d "$path" ]; then rm -rf -- "$path" - elif [ -e "$path" ]; then rm -f -- "$path" - fi - } - - restore_all() { - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort -r); do - destination=$(cat -- "$entry_root/destination") || return 1 - backup=$(cat -- "$entry_root/backup") || return 1 - if [ -f "$entry_root/installed" ]; then - remove_no_follow "$destination" || return 1 - rm -f -- "$entry_root/installed" - fi - if [ -f "$entry_root/backed-up" ]; then - mv -- "$backup" "$destination" || return 1 - rm -f -- "$entry_root/backed-up" - fi - done - } - - operation_root=$1 - operation_id=$2 - validate_operation "$operation_root" "$operation_id" || exit 61 - for entry_root in $(find "$operation_root" -mindepth 1 -maxdepth 1 -type d -name 'entry-*' | LC_ALL=C sort); do - stage=$(cat -- "$entry_root/stage") || exit 62 - backup=$(cat -- "$entry_root/backup") || exit 63 - path=$stage - if [ -n "$path" ]; then - name=${path##*/} - case "$name" in .skill-deck-stage-$operation_id-*) ;; - *) exit 64 ;; - esac - remove_no_follow "$path" || exit 65 - fi - if [ -f "$entry_root/backed-up" ]; then - name=${backup##*/} - case "$name" in .skill-deck-backup-$operation_id-*) ;; - *) exit 66 ;; - esac - remove_no_follow "$backup" || exit 67 - fi - done - rm -rf -- "$operation_root" || exit 68 - printf '1\0' - - ;; - *) - printf 'unknown Skill Deck WSL materialize operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/projects.sh b/src-tauri/src/environment/wsl/scripts/projects.sh deleted file mode 100644 index a17b277d..00000000 --- a/src-tauri/src/environment/wsl/scripts/projects.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - project-storage) - printf '1\0'; for path do if mapped=$(wslpath -w -- "$path" 2>/dev/null); then printf 'ok\0%s\0' "$mapped"; else printf 'error\0\0'; fi; done - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/environment/wsl/scripts/recovery.sh b/src-tauri/src/environment/wsl/scripts/recovery.sh deleted file mode 100644 index 3bed56b6..00000000 --- a/src-tauri/src/environment/wsl/scripts/recovery.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/sh -subcommand=$1 -shift -case "$subcommand" in - enumerate) - - namespace=$1 - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 61 - printf '1\0' - for root in "$namespace"/skill-deck-operation-*; do - [ -e "$root" ] || [ -L "$root" ] || continue - status=missing - content= - if [ -d "$root" ] && [ ! -L "$root" ]; then - marker=$root/recovery.json - if [ -f "$marker" ] && [ ! -L "$marker" ]; then - status=present - content=$(cat -- "$marker") || status=unreadable - fi - else - status=unsafe - fi - printf 'R\0%s\0%s\0%s\0' "$root" "$status" "$content" - done - - ;; - write-marker) - - namespace=$1 - resource_id=$2 - mode=$3 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - created_root=0 - case "$mode" in - create) - [ ! -e "$root" ] && [ ! -L "$root" ] || exit 63 - umask 077 - mkdir -- "$root" || exit 64 - created_root=1 - printf '1\n%s\n' "$resource_id" > "$root/.skill-deck-owner" || exit 65 - ;; - update) - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - ;; - *) exit 70 ;; - esac - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - marker=$root/recovery.json - case "$mode" in - create) [ ! -e "$marker" ] && [ ! -L "$marker" ] || exit 68 ;; - update) [ -f "$marker" ] && [ ! -L "$marker" ] || exit 69 ;; - *) exit 70 ;; - esac - tmp=$root/.recovery.$$ - trap 'rm -f -- "$tmp"; if [ "$created_root" = 1 ]; then rm -rf -- "$root"; fi' EXIT HUP INT TERM - umask 077 - cat > "$tmp" || exit 71 - [ -s "$tmp" ] || exit 72 - sync "$tmp" 2>/dev/null || true - mv -- "$tmp" "$marker" || exit 73 - created_root=0 - printf '1\0' - trap - EXIT HUP INT TERM - - ;; - remove-marker) - - namespace=$1 - resource_id=$2 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - [ ! -e "$root" ] && [ ! -L "$root" ] && { printf '1\0'; exit 0; } - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - rm -rf -- "$root" || exit 68 - printf '1\0' - - ;; - cleanup) - - namespace=$1 - resource_id=$2 - shift 2 - case "$resource_id" in ''|*[!A-Za-z0-9_-]*) exit 61 ;; esac - [ -d "$namespace" ] && [ ! -L "$namespace" ] || exit 62 - root=$namespace/skill-deck-operation-$resource_id - [ -d "$root" ] && [ ! -L "$root" ] || exit 63 - owner=$root/.skill-deck-owner - [ -f "$owner" ] && [ ! -L "$owner" ] || exit 64 - [ "$(wc -l < "$owner")" -eq 2 ] || exit 65 - [ "$(sed -n '1p' "$owner")" = 1 ] || exit 66 - [ "$(sed -n '2p' "$owner")" = "$resource_id" ] || exit 67 - marker=$root/recovery.json - [ -f "$marker" ] && [ ! -L "$marker" ] || exit 68 - expected=$root/.cleanup-expected.$$ - trap 'rm -f -- "$expected"' EXIT HUP INT TERM - umask 077 - cat > "$expected" || exit 69 - [ -s "$expected" ] || exit 70 - cmp -s -- "$expected" "$marker" || exit 71 - for backup in "$@"; do - case "$backup" in - /*) ;; - *) exit 72 ;; - esac - case "$backup" in - */../*|*/./*|*/..|*/.) exit 73 ;; - esac - name=${backup##*/} - case "$name" in .skill-deck-backup-*) ;; *) exit 74 ;; esac - [ "$backup" != "$root" ] || exit 75 - rm -rf -- "$backup" || exit 76 - done - rm -f -- "$expected" || exit 77 - trap - EXIT HUP INT TERM - rm -rf -- "$root" || exit 78 - printf '1\0' - - ;; - *) - printf 'unknown Skill Deck WSL operation: %s\n' "$subcommand" >&2 - exit 64 - ;; -esac diff --git a/src-tauri/src/runtime/plan_runner.rs b/src-tauri/src/runtime/plan_runner.rs index f29b80de..6ace1ee5 100644 --- a/src-tauri/src/runtime/plan_runner.rs +++ b/src-tauri/src/runtime/plan_runner.rs @@ -9,27 +9,27 @@ use crate::application::mutation::plan::{MutationPlan, RuntimeRevisions}; use crate::application::mutation::result::{ErrorReport, MutationUnitResult, MutationUnitStatus}; use crate::core::mutation::CancellationSignal; use crate::environment::native::atomic_file::NativeAtomicDocumentIo; -use crate::environment::native::materialize::NativePreparedEntryExecutor; +use crate::environment::native::materialize::{ + NativePreparedEntryExecutor, NativePreparedUnitExecutor, +}; use crate::environment::recovery::RecoveryMarkerStore; use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{ normalized_wsl_distro_name, same_environment_identity, EnvironmentKey, EnvironmentRef, SkillLocationRef, }; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::materialize::WslPreparedEntryExecutor; +use crate::environment::wsl::operations::materialize::WslPreparedUnitExecutor; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::runtime::recovery::{RuntimeRecoveryGraph, RuntimeRecoveryService}; use crate::storage::lock_plan::{LockCommitReceipt, LockPlanCommitter, PreparedLockMutation}; -pub struct RuntimeLockCommitter { - environments: Arc, -} +#[derive(Default)] +pub struct RuntimeLockCommitter; impl RuntimeLockCommitter { - pub fn new(environments: Arc) -> Self { - Self { environments } + pub fn new() -> Self { + Self } } @@ -39,20 +39,12 @@ impl PreparedLockCommitter for RuntimeLockCommitter { mutation: &'a PreparedLockMutation, ) -> BoxFuture<'a, Result> { Box::pin(async move { - match &mutation.target.environment { - EnvironmentRef::Native => { - LockPlanCommitter::new(Arc::new(NativeAtomicDocumentIo)) - .commit(mutation.clone()) - .await - } - EnvironmentRef::Wsl { distro_name } => { - let mutation = mutation.clone(); - let workspace = self.environments.workspace(distro_name)?; - LockPlanCommitter::new(Arc::new(WslAtomicDocumentIo::new(workspace))) - .commit(mutation) - .await - } + if mutation.target.environment != EnvironmentRef::Native { + return Err(AppError::StaleEnvironment); } + LockPlanCommitter::new(Arc::new(NativeAtomicDocumentIo)) + .commit(mutation.clone()) + .await }) } } @@ -81,7 +73,7 @@ impl RuntimeExecutionDependencies { environments.clone(), recovery_root, )?), - locks: Arc::new(RuntimeLockCommitter::new(environments)), + locks: Arc::new(RuntimeLockCommitter::new()), }) } @@ -162,8 +154,7 @@ impl RuntimePlanExecutor { Arc::clone(&self.native_recovery), ); MutationCoordinator::new( - entries, - SharedLocks(Arc::clone(&self.locks)), + NativePreparedUnitExecutor::new(entries, SharedLocks(Arc::clone(&self.locks))), SharedRevisions(Arc::clone(&self.revisions)), ) .execute_with_observer(plan, cancellation, observer) @@ -182,7 +173,6 @@ impl RuntimePlanExecutor { } let failure_units = failure_units(&plan); let plan = Arc::new(Mutex::new(Some(plan))); - let locks = Arc::clone(&self.locks); let revisions = Arc::clone(&self.revisions); let recovery_graph = self.recovery_graph.clone(); let cancellation_for_run = cancellation.clone(); @@ -192,7 +182,6 @@ impl RuntimePlanExecutor { .with_session(&distro_name, move |session| { let workspace = workspace.clone(); let plan = plan.lock().expect("WSL plan handoff lock poisoned").take(); - let locks = Arc::clone(&locks); let revisions = Arc::clone(&revisions); let recovery_graph = recovery_graph.clone(); let cancellation = cancellation_for_run.clone(); @@ -202,29 +191,19 @@ impl RuntimePlanExecutor { message: "WSL mutation plan was consumed more than once" .to_string(), })?; - let entries = match &recovery_graph { - Some(graph) => { - let store = graph.active_wsl_store(session.clone())?; - WslPreparedEntryExecutor::with_recovery_store_for_operation( - session, - plan.operation_id.clone(), - plan.kind, - store, - ) - } - None => WslPreparedEntryExecutor::for_operation( - session, - plan.operation_id.clone(), - plan.kind, - ), - }; - let results = MutationCoordinator::new( - entries, - SharedLocks(locks), - SharedRevisions(revisions), - ) - .execute_with_observer(plan, cancellation, observer) - .await; + if let Some(graph) = &recovery_graph { + let _ = graph.active_wsl_store(workspace.clone())?; + } + let units = WslPreparedUnitExecutor::for_operation( + session, + workspace.clone(), + plan.operation_id.clone(), + plan.kind, + ); + let results = + MutationCoordinator::new(units, SharedRevisions(revisions)) + .execute_with_observer(plan, cancellation, observer) + .await; if let Some(graph) = recovery_graph { graph.wsl_store(workspace)?; } @@ -580,7 +559,7 @@ mod tests { root_replacements: BTreeMap::new(), expected: LockExpectedState::capture(&document, ["demo"], std::iter::empty::<&str>()), }; - let committer = RuntimeLockCommitter::new(Arc::new(WslRuntime::default())); + let committer = RuntimeLockCommitter::new(); committer.commit(&mutation).await.unwrap(); diff --git a/src-tauri/src/runtime/recovery.rs b/src-tauri/src/runtime/recovery.rs index ce60264d..8c4e91e2 100644 --- a/src-tauri/src/runtime/recovery.rs +++ b/src-tauri/src/runtime/recovery.rs @@ -12,7 +12,7 @@ use crate::environment::recovery::{ use crate::environment::types::EnvironmentRef; use crate::environment::wsl::operations::entry::{inspect_entries, PosixEntryKind}; use crate::environment::wsl::operations::recovery::WslRecoveryMarkerStore; -use crate::environment::wsl::{WslRuntime, WslSession, WslWorkspace}; +use crate::environment::wsl::{WslRuntime, WslWorkspace}; use crate::error::AppError; use crate::storage::recovery_repository::{ RecoveryConsistency, RecoveryConsistencyChecker, RecoveryRepository, @@ -44,14 +44,8 @@ impl RuntimeRecoveryConsistencyChecker { } EnvironmentRef::Wsl { distro_name } => { let marker = marker.clone(); - match self - .environments - .with_session(distro_name, move |session| { - let marker = marker.clone(); - async move { check_wsl_marker(&session, &marker).await } - }) - .await - { + let workspace = self.environments.workspace(distro_name)?; + match check_wsl_marker(&workspace, &marker).await { Ok(consistency) => Ok(consistency), Err(error) if environment_unavailable(&error) => { Ok(RecoveryConsistency::EnvironmentUnavailable) @@ -92,7 +86,7 @@ fn check_native_marker(marker: &RecoveryMarker) -> Result Result { for entry in &marker.entries { @@ -100,7 +94,7 @@ async fn check_wsl_marker( if let Some(backup) = &entry.backup { paths.push(backup.native_path.clone()); } - let states = inspect_entries(session, &paths, None).await?; + let states = inspect_entries(workspace, &paths, None).await?; let destination = &states[0].fingerprint.0; let backup_exists = states .get(1) @@ -197,13 +191,13 @@ impl RuntimeRecoveryGraph { pub(crate) fn active_wsl_store( &self, - session: WslSession, + workspace: WslWorkspace, ) -> Result, AppError> { let environment = EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + distro_name: workspace.distro_name().to_string(), }; let underlying: Arc = - Arc::new(WslRecoveryMarkerStore::from_active_session(session)); + Arc::new(WslRecoveryMarkerStore::new(workspace)); self.repository.register_store(underlying)?; Ok(Arc::new(RepositoryRecoveryMarkerStore::new( environment, diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index 8324ea0a..5ffbf9f9 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -12,6 +12,9 @@ use crate::application::library_application::{ library_usage_state, LibraryApplicationFuture, LibraryApplicationRecord, LibraryApplicationRepository, LibraryUsageAccumulator, LIBRARY_APPLICATION_SCHEMA_VERSION, }; +use crate::application::payload_session::{ + PayloadLocalSource, PayloadSessionStorage, PayloadStorageKey, +}; use crate::application::skill_libraries::{ validate_catalog, CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, LibraryMemberMutation, LibraryUsage, LibraryUsageProjection, LibraryUsageProvider, @@ -22,16 +25,12 @@ use crate::core::projects::ProjectMigrationRegistry; use crate::core::skill_payload::SkillPayload; use crate::environment::native::atomic_file::NativeAtomicDocumentIo; use crate::environment::native::entry::{materialize_payload, verify_materialized_payload}; +use crate::environment::runtime::PhysicalParentIdentity; use crate::environment::types::{ EnvironmentKey, EnvironmentRef, ProjectInfo, RegisteredProject, ResourceLocator, }; use crate::environment::types::{SkillLocation, SkillLocationRef}; -use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; -use crate::environment::wsl::operations::library_content::{ - ensure_library_roots, finalize_library_catalog, prepare_library_catalog, - recover_library_content, remove_library as remove_wsl_library, remove_library_application, - replace_library_skill, stage_library_skill_deletion, -}; +use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::storage::atomic_document::AtomicDocumentIo; @@ -207,15 +206,9 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { })?? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); self.wsl - .with_session_retry(&distro_name, |session| async move { - recover_wsl_library_content(&session).await?; - let target = wsl_catalog_locator(&session); - WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&target) - .await - }) + .workspace(distro_name)? + .read_library_catalog() .await? } }; @@ -235,7 +228,6 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { Box::pin(async move { let _io = self.io.acquire(environment).await; let bytes = serde_json::to_vec_pretty(catalog)?; - let catalog_hash = bytes_sha256(&bytes); match environment { EnvironmentRef::Native => { let root = self.native_root.clone(); @@ -253,37 +245,27 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { })? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - self.wsl - .with_session_retry(&distro_name, move |session| { - let bytes = bytes.clone(); - let catalog_hash = catalog_hash.clone(); - let library_ids = catalog - .libraries - .iter() - .map(|library| library.id.as_str().to_string()) - .collect::>(); - async move { - let result = async { - recover_wsl_library_content(&session).await?; - ensure_library_roots(&session, &library_ids).await?; - prepare_library_catalog(&session, &catalog_hash).await?; - let target = wsl_catalog_locator(&session); - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(&target, bytes) - .await?; - finalize_library_catalog(&session, &catalog_hash).await - } - .await; - let result = if result.is_err() { - recover_wsl_library_content(&session).await.and(result) - } else { - result - }; - result - } - }) + let workspace = self.wsl.workspace(distro_name)?; + let snapshot = workspace.read_library_catalog_once().await?; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes: bytes, + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + }, + deadline_millis: 60_000, + }, + ) .await + .map(|_| ()) } } }) @@ -307,10 +289,12 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { return Err(AppError::StaleEnvironment); }; let distro_name = distro_name.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let request = request.clone(); - async move { commit_wsl_member(&session, request).await } + let workspace = workspace.clone(); + async move { commit_wsl_member(&session, &workspace, request).await } }) .await }) @@ -337,10 +321,14 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let library_id = library_id.clone(); - async move { delete_wsl_library(&session, &library_id).await } + let workspace = workspace.clone(); + async move { + delete_wsl_library(&session, &workspace, &library_id).await + } }) .await } @@ -373,22 +361,28 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); let skill_name = install_dir_name; - self.wsl + let path = self + .wsl .with_session_retry(&distro_name, move |session| { let library_id = library_id.clone(); let skill_name = skill_name.clone(); async move { - let path = format!( + Ok(format!( "{}/.skill-deck/skill-libraries/libraries/{}/skills/{}", session.home.trim_end_matches('/'), library_id, skill_name - ); - let markdown = crate::environment::wsl::operations::skill_content::read_skill_markdown(&session, &path).await?; - Ok(crate::core::skill::skill_content_from_markdown(&markdown)) + )) } }) - .await + .await?; + let workspace = self.wsl.workspace(&distro_name)?; + let markdown = + crate::environment::wsl::operations::skill_content::read_skill_markdown( + &workspace, &path, + ) + .await?; + Ok(crate::core::skill::skill_content_from_markdown(&markdown)) } } }) @@ -483,13 +477,18 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let context = context.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl .with_session_retry(&distro_name, move |session| { let context = context.clone(); + let workspace = workspace.clone(); async move { let target = wsl_application_locator(&session, &context.scope)?; - WslAtomicDocumentIo::from_active_session(session) - .read_optional(&target) + workspace + .read_optional_document( + target.native_path, + environment_protocol::MAX_DOCUMENT_BYTES, + ) .await } }) @@ -498,10 +497,8 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { }; let record = bytes .map(|bytes| serde_json::from_slice(&bytes).map_err(AppError::from)) - .unwrap_or_else(|| Ok(LibraryApplicationRecord::empty(context.clone())))?; - if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION - || record.target != *context - { + .unwrap_or_else(|| Ok(LibraryApplicationRecord::empty()))?; + if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION { return Err(AppError::ConfigurationCorrupted { message: "invalid Skill Library application record".to_string(), }); @@ -512,28 +509,43 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { fn save_application<'a>( &'a self, + context: &'a SkillLocationRef, record: &'a LibraryApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { let bytes = serde_json::to_vec_pretty(record)?; - match &record.target.environment { + match &context.environment { EnvironmentRef::Native => { NativeAtomicDocumentIo - .write_atomic(&self.native_application(&record.target)?, bytes) + .write_atomic(&self.native_application(context)?, bytes) .await } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); - let scope = record.target.scope.clone(); + let scope = context.scope.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session(&distro_name, move |session| { let bytes = bytes.clone(); let scope = scope.clone(); + let workspace = workspace.clone(); async move { let target = wsl_application_locator(&session, &scope)?; - WslAtomicDocumentIo::from_active_session(session) - .write_atomic(&target, bytes) + let snapshot = workspace + .read_optional_document_snapshot_once( + target.native_path.clone(), + environment_protocol::MAX_DOCUMENT_BYTES, + ) + .await?; + workspace + .write_document_atomic( + snapshot.generation, + target.native_path, + snapshot.revision, + bytes, + ) .await + .map(|_| ()) } }) .await @@ -621,11 +633,28 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); - let project_id = project_id.clone(); + let scope = context.scope.clone(); + let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { - let project_id = project_id.clone(); - async move { remove_library_application(&session, &project_id).await } + .with_session(&distro_name, move |session| { + let scope = scope.clone(); + let workspace = workspace.clone(); + async move { + let target = wsl_application_locator(&session, &scope)?; + let snapshot = workspace + .read_optional_document_snapshot_once( + target.native_path.clone(), + environment_protocol::MAX_DOCUMENT_BYTES, + ) + .await?; + workspace + .remove_document_if_revision( + snapshot.generation, + target.native_path, + snapshot.revision, + ) + .await + } }) .await } @@ -726,32 +755,59 @@ fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result Result { - recover_wsl_library_content(session).await?; - let target = wsl_catalog_locator(session); - let io = WslAtomicDocumentIo::from_active_session(session.clone()); - let original_bytes = - io.read_optional(&target) - .await? - .ok_or_else(|| AppError::PathNotFound { - path: library_id.to_string(), - })?; + let snapshot = workspace.read_library_catalog_once().await?; + if snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let original_bytes = snapshot.bytes.ok_or_else(|| AppError::PathNotFound { + path: library_id.to_string(), + })?; let mut catalog: LibraryCatalog = serde_json::from_slice(&original_bytes)?; validate_catalog(&catalog)?; remove_catalog_library(&mut catalog, library_id)?; let updated_bytes = serde_json::to_vec_pretty(&catalog)?; - let updated_hash = bytes_sha256(&updated_bytes); - prepare_library_catalog(session, &updated_hash).await?; - io.write_atomic(&target, updated_bytes).await?; - finalize_library_catalog(session, &updated_hash).await?; - if let Err(error) = remove_wsl_library(session, library_id).await { - let original_hash = bytes_sha256(&original_bytes); - prepare_library_catalog(session, &original_hash).await?; - io.write_atomic(&target, original_bytes).await?; - finalize_library_catalog(session, &original_hash).await?; - return Err(error); - } + let destination = format!( + "{}/.skill-deck/skill-libraries/libraries/{library_id}", + session.home.trim_end_matches('/'), + ); + let target = crate::environment::planning::resolve_wsl_targets( + session, + workspace, + std::slice::from_ref(&destination), + None, + ) + .await? + .pop() + .ok_or(AppError::StaleTarget)?; + let (expected_anchor_device, expected_anchor_inode) = match &target.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (*device, *inode), + _ => return Err(AppError::StaleTarget), + }; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes: updated_bytes, + action: environment_protocol::LibraryOperationAction::DeleteLibrary { + library_id: library_id.to_string(), + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: target.fingerprint.0, + expected_content_hash: None, + }, + deadline_millis: 60_000, + }, + ) + .await?; Ok(catalog) } @@ -770,9 +826,9 @@ fn remove_catalog_library(catalog: &mut LibraryCatalog, library_id: &str) -> Res async fn commit_wsl_member( session: &crate::environment::wsl::WslSession, + workspace: &crate::environment::wsl::WslWorkspace, request: CommitLibraryMemberRequest, ) -> Result<(), AppError> { - recover_wsl_library_content(session).await?; validate_storage_component(request.library_id.as_str())?; let install_dir_name = InstalledSkillResolver::install_dir_name(&request.skill_name)?; let destination = format!( @@ -783,6 +839,7 @@ async fn commit_wsl_member( ); let target = crate::environment::planning::resolve_wsl_targets( session, + workspace, std::slice::from_ref(&destination), None, ) @@ -793,7 +850,7 @@ async fn commit_wsl_member( { Some( crate::environment::wsl::operations::content_manifest::inspect( - session, + workspace, &crate::environment::content_manifest::ContentManifestTarget { key: target.key.clone(), location: target.destination.clone(), @@ -809,7 +866,8 @@ async fn commit_wsl_member( }; let (target_revision, content_revision) = crate::application::skill_paths::SkillPathObserver::revisions_for_observation( - &target, manifest, + &target, + manifest.clone(), )?; if target_revision != request.expected.target_revision || content_revision != request.expected.content_revision @@ -817,11 +875,24 @@ async fn commit_wsl_member( return Err(AppError::StaleTarget); } - let catalog_target = wsl_catalog_locator(session); - let bytes = WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&catalog_target) - .await?; - let mut catalog = bytes + let (expected_anchor_device, expected_anchor_inode) = match &target.key.physical_parent { + PhysicalParentIdentity::Wsl { + distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (*device, *inode), + _ => return Err(AppError::StaleTarget), + }; + let expected_fingerprint = target.fingerprint.0.clone(); + let expected_content_hash = manifest + .as_ref() + .map(|manifest| manifest.as_str().to_string()); + let catalog_snapshot = workspace.read_library_catalog_once().await?; + if catalog_snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let mut catalog = catalog_snapshot + .bytes .as_deref() .map(serde_json::from_slice) .transpose()? @@ -884,58 +955,61 @@ async fn commit_wsl_member( } } let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; - let catalog_hash = bytes_sha256(&catalog_bytes); - let commit = async { - match &request.mutation { - LibraryMemberMutation::Upsert { content, .. } => { - replace_library_skill( - session, - request.library_id.as_str(), - &install_dir_name, - payload_archive(content)?, - ) - .await?; - } - LibraryMemberMutation::Delete => { - stage_library_skill_deletion( - session, - request.library_id.as_str(), - &install_dir_name, - ) + let payload_storage = WslPayloadSessionStorage::new(workspace.clone()); + let payload_key = PayloadStorageKey::new( + format!("library-{}", uuid::Uuid::new_v4().simple()), + install_dir_name.clone(), + ); + let mutation = match &request.mutation { + LibraryMemberMutation::Upsert { content, .. } => { + payload_storage + .store(&payload_key, (**content).clone()) .await?; + match payload_storage.local_source(&payload_key)? { + PayloadLocalSource::WslManaged { + distro_name, + worker_generation, + worker_payload_id, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) + && worker_generation == catalog_snapshot.generation => + { + environment_protocol::LibraryMemberAction::Upsert { + payload_id: worker_payload_id, + } + } + _ => { + let _ = payload_storage.remove(&payload_key).await; + return Err(AppError::StalePayload); + } } } - prepare_library_catalog(session, &catalog_hash).await?; - WslAtomicDocumentIo::from_active_session(session.clone()) - .write_atomic(&catalog_target, catalog_bytes) - .await?; - finalize_library_catalog(session, &catalog_hash).await - } - .await; - if let Err(error) = commit { - recover_wsl_library_content(session).await?; - let current = WslAtomicDocumentIo::from_active_session(session.clone()) - .read_optional(&catalog_target) - .await?; - if current.as_deref().map(bytes_sha256).as_deref() == Some(&catalog_hash) { - return Ok(()); - } - return Err(error); - } - Ok(()) -} - -async fn recover_wsl_library_content( - session: &crate::environment::wsl::WslSession, -) -> Result<(), AppError> { - recover_library_content(session) - .await - .map_err(|error| AppError::LibraryRecoveryIncomplete { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), + LibraryMemberMutation::Delete => environment_protocol::LibraryMemberAction::Delete, + }; + let result = workspace + .execute_library_operation( + catalog_snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: catalog_snapshot.revision, + catalog_bytes, + action: environment_protocol::LibraryOperationAction::CommitMember { + library_id: request.library_id.as_str().to_string(), + skill_name: install_dir_name, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint, + expected_content_hash, + mutation, + }, + deadline_millis: 60_000, }, - message: error.to_string(), - }) + ) + .await + .map(|_| ()); + if matches!(request.mutation, LibraryMemberMutation::Upsert { .. }) { + let _ = payload_storage.remove(&payload_key).await; + } + result } fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Result<(), AppError> { @@ -1356,35 +1430,6 @@ fn bytes_sha256(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } -fn payload_archive(payload: &SkillPayload) -> Result, AppError> { - crate::core::skill_payload::verify_skill_payload_integrity(payload)?; - let mut builder = tar::Builder::new(Vec::new()); - for entry in &payload.entries { - let mut header = tar::Header::new_gnu(); - header.set_path(Path::new("stage").join(&entry.relative_path))?; - match entry.kind { - crate::core::skill_payload::PayloadEntryKind::Directory => { - header.set_entry_type(tar::EntryType::Directory); - header.set_mode(0o755); - header.set_size(0); - header.set_cksum(); - builder.append(&header, std::io::empty())?; - } - crate::core::skill_payload::PayloadEntryKind::File => { - let blob_id = entry.blob_id.as_deref().ok_or(AppError::StalePayload)?; - let content = payload.blobs.get(blob_id).ok_or(AppError::StalePayload)?; - header.set_entry_type(tar::EntryType::Regular); - header.set_mode(if entry.executable { 0o755 } else { 0o644 }); - header.set_size(content.len() as u64); - header.set_cksum(); - builder.append(&header, content.as_slice())?; - } - } - } - builder.finish()?; - Ok(builder.into_inner()?) -} - fn validate_storage_component(value: &str) -> Result<(), AppError> { if value.is_empty() || matches!(value, "." | "..") || value.contains(['/', '\\', '\0']) { return Err(AppError::Validation { @@ -1395,18 +1440,6 @@ fn validate_storage_component(value: &str) -> Result<(), AppError> { Ok(()) } -fn wsl_catalog_locator(session: &crate::environment::wsl::WslSession) -> ResourceLocator { - ResourceLocator { - environment: EnvironmentRef::Wsl { - distro_name: session.distro_name.clone(), - }, - native_path: format!( - "{}/.skill-deck/skill-libraries/catalog.json", - session.home.trim_end_matches('/') - ), - } -} - fn application_relative_path(scope: &SkillLocation) -> Result { match scope { SkillLocation::Global => Ok(PathBuf::from("global.json")), @@ -1951,19 +1984,25 @@ mod tests { project_id: "project-1".to_string(), }, }; - let mut global_record = LibraryApplicationRecord::empty(global.clone()); + let mut global_record = LibraryApplicationRecord::empty(); global_record.current = LibraryApplicationState { ordered_library_ids: vec![LibraryId::parse("global-library")], selected_agent_ids: Vec::new(), }; - let mut project_record = LibraryApplicationRecord::empty(project.clone()); + let mut project_record = LibraryApplicationRecord::empty(); project_record.current = LibraryApplicationState { ordered_library_ids: vec![LibraryId::parse("project-library")], selected_agent_ids: Vec::new(), }; - repository.save_application(&global_record).await.unwrap(); - repository.save_application(&project_record).await.unwrap(); + repository + .save_application(&global, &global_record) + .await + .unwrap(); + repository + .save_application(&project, &project_record) + .await + .unwrap(); assert_eq!( repository.load_application(&global).await.unwrap(), @@ -1974,4 +2013,45 @@ mod tests { project_record ); } + + #[tokio::test] + async fn application_record_storage_uses_its_repository_context() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "target": { + "environment": { "kind": "wsl", "distro_name": "Ubuntu" }, + "scope": { "scope": "global" } + }, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "pendingOperation": null + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + let record = repository.load_application(&context).await.unwrap(); + assert_eq!(record.current, LibraryApplicationState::default()); + repository + .save_application(&context, &record) + .await + .unwrap(); + + let stored: serde_json::Value = + serde_json::from_slice(&fs::read(applications.join("global.json")).unwrap()).unwrap(); + assert!(stored.get("target").is_none()); + } } diff --git a/src-tauri/src/storage/lock_plan.rs b/src-tauri/src/storage/lock_plan.rs index 25d511d5..6f9f95a7 100644 --- a/src-tauri/src/storage/lock_plan.rs +++ b/src-tauri/src/storage/lock_plan.rs @@ -78,13 +78,6 @@ pub enum LockEntryMutation { } impl LockEntryMutation { - fn affected_keys(&self) -> Vec<&str> { - match self { - Self::Replace { key, .. } | Self::Remove { key } => vec![key], - Self::MoveAndReplace { from, to, .. } => vec![from, to], - } - } - #[cfg(test)] pub fn target_key(&self) -> &str { match self { @@ -142,95 +135,103 @@ where &self, prepared: PreparedLockMutation, ) -> Result { - let mut latest = self.load_latest(&prepared).await?; - for key in prepared.entry.affected_keys() { - let expected = prepared.expected.entry_snapshots.get(key).ok_or_else(|| { - AppError::InvalidSource { - value: format!("lock plan did not capture Skill '{key}'"), - } - })?; - latest.validate_entry_snapshot(key, expected)?; - } - match prepared.entry { - LockEntryMutation::Replace { key, replacement } => latest.replace_entry( - prepared.schema, - &key, - prepared - .expected - .entry_snapshots - .get(&key) - .expect("validated snapshot"), - replacement, - )?, - LockEntryMutation::Remove { key } => latest.remove_entry( - &key, - prepared - .expected - .entry_snapshots - .get(&key) - .expect("validated snapshot"), - )?, + let current = self.io.read_optional(&prepared.target).await?; + let legacy = match (¤t, &prepared.legacy_target) { + (None, Some(target)) => self.io.read_optional(target).await?, + _ => None, + }; + let applied = environment_engine::lock::apply( + current.as_deref(), + legacy.as_deref(), + &engine_mutation(&prepared), + ) + .map_err(map_engine_error)?; + self.io + .write_atomic(&prepared.target, applied.bytes) + .await?; + Ok(LockCommitReceipt { + entry_snapshots: applied + .receipt + .entries + .into_iter() + .map(|(key, value)| (key, LockEntrySnapshot::from_value(value))) + .collect(), + root_snapshots: applied + .receipt + .roots + .into_iter() + .map(|(field, value)| (field, LockRootSnapshot::from_value(value))) + .collect(), + }) + } +} + +fn engine_mutation(prepared: &PreparedLockMutation) -> environment_engine::lock::LockMutation { + use environment_engine::lock::{EntryMutation, LockMutation, LockSchema as EngineSchema}; + + LockMutation { + schema: match prepared.schema { + LockSchema::Global => EngineSchema::Global, + LockSchema::Project => EngineSchema::Project, + }, + entry: match &prepared.entry { + LockEntryMutation::Replace { key, replacement } => EntryMutation::Replace { + key: key.clone(), + replacement: replacement.clone(), + }, + LockEntryMutation::Remove { key } => EntryMutation::Remove { key: key.clone() }, LockEntryMutation::MoveAndReplace { from, to, replacement, - } => latest.move_and_replace_entry( - prepared.schema, - &from, - &to, - prepared - .expected - .entry_snapshots - .get(&from) - .expect("validated snapshot"), - prepared - .expected - .entry_snapshots - .get(&to) - .expect("validated snapshot"), - replacement, - )?, - } - for (field, replacement) in &prepared.root_replacements { - let expected = prepared.expected.root_snapshots.get(field).ok_or_else(|| { - AppError::InvalidSource { - value: format!("lock plan did not capture root field '{field}'"), - } - })?; - latest.replace_root(field, expected, replacement.clone())?; - } - - let receipt = LockCommitReceipt { - entry_snapshots: prepared - .expected - .entry_snapshots - .keys() - .map(|name| (name.clone(), latest.entry_snapshot(name))) - .collect(), - root_snapshots: prepared - .expected - .root_snapshots - .keys() - .map(|field| (field.clone(), latest.root_snapshot(field))) - .collect(), - }; - self.io - .write_atomic(&prepared.target, latest.to_pretty_bytes()?) - .await?; - Ok(receipt) + } => EntryMutation::MoveAndReplace { + from: from.clone(), + to: to.clone(), + replacement: replacement.clone(), + }, + }, + root_replacements: prepared.root_replacements.clone(), + expected_entries: prepared + .expected + .entry_snapshots + .iter() + .map(|(key, snapshot)| (key.clone(), snapshot.value().cloned())) + .collect(), + expected_roots: prepared + .expected + .root_snapshots + .iter() + .map(|(field, snapshot)| (field.clone(), snapshot.value().cloned())) + .collect(), } +} - async fn load_latest( - &self, - prepared: &PreparedLockMutation, - ) -> Result { - load_lock_document( - self.io.as_ref(), - &prepared.target, - prepared.legacy_target.as_ref(), - prepared.schema, - ) - .await +fn map_engine_error(error: environment_engine::lock::LockError) -> AppError { + match error { + environment_engine::lock::LockError::EntryConflict { key } => AppError::LockConflict { + target: crate::error::LockConflictTarget::Skill { skill_name: key }, + }, + environment_engine::lock::LockError::RootConflict { field } => AppError::LockConflict { + target: crate::error::LockConflictTarget::RootField { field }, + }, + environment_engine::lock::LockError::MissingExpectedEntry { key } => { + AppError::InvalidSource { + value: format!("lock plan did not capture Skill '{key}'"), + } + } + environment_engine::lock::LockError::MissingExpectedRoot { field } => { + AppError::InvalidSource { + value: format!("lock plan did not capture root field '{field}'"), + } + } + environment_engine::lock::LockError::UnsupportedSchema { version, supported } => { + AppError::ConfigurationCorrupted { + message: format!("lock schema version {version} is newer than {supported}"), + } + } + environment_engine::lock::LockError::InvalidDocument { message } => { + AppError::Json { message } + } } } diff --git a/src-tauri/src/test_support/native_workflow.rs b/src-tauri/src/test_support/native_workflow.rs index 256baf48..c4aa2321 100644 --- a/src-tauri/src/test_support/native_workflow.rs +++ b/src-tauri/src/test_support/native_workflow.rs @@ -146,6 +146,7 @@ impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { fn save_application<'a>( &'a self, + _context: &'a SkillLocationRef, record: &'a LibraryApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { @@ -188,11 +189,10 @@ impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { fn remove_application<'a>( &'a self, - context: &'a SkillLocationRef, + _context: &'a SkillLocationRef, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { - *self.record.lock().expect("library record lock") = - LibraryApplicationRecord::empty(context.clone()); + *self.record.lock().expect("library record lock") = LibraryApplicationRecord::empty(); Ok(()) }) } @@ -314,13 +314,11 @@ impl PreparedEntryExecutor for VerifyFailureEntryExecutor { } struct VerifyFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, } struct SelectiveVerifyFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, failing_skill: String, @@ -371,7 +369,7 @@ impl MutationPlanExecutor for LockFailurePlanExecutor { plan.operation_id.clone(), recovery, ); - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, RejectingLockCommitter { attempted: Arc::clone(&self.attempted), @@ -406,9 +404,9 @@ impl MutationPlanExecutor for VerifyFailurePlanExecutor { recovery, ), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - crate::runtime::plan_runner::RuntimeLockCommitter::new(self.environments.clone()), + crate::runtime::plan_runner::RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -440,9 +438,9 @@ impl MutationPlanExecutor for SelectiveVerifyFailurePlanExecutor { ), failing_skill: self.failing_skill.clone(), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - crate::runtime::plan_runner::RuntimeLockCommitter::new(self.environments.clone()), + crate::runtime::plan_runner::RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -827,7 +825,6 @@ async fn run_native_workflow_integration() -> Result<(), AppError> { payloads.clone(), InstalledSkillPayloadAcquirer::new(payloads.clone(), environments.clone()), VerifyFailurePlanExecutor { - environments: environments.clone(), facts: facts.clone(), recovery_root: recovery_root.clone(), }, @@ -1429,7 +1426,7 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() let second_id = LibraryId::parse("library-two"); let repository: Arc = Arc::new(MemoryLibraryApplicationRepository { - record: Mutex::new(LibraryApplicationRecord::empty(context.clone())), + record: Mutex::new(LibraryApplicationRecord::empty()), catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, libraries: vec![ @@ -2049,7 +2046,6 @@ async fn direct_download_flows_from_http_discovery_through_install_without_lock( Arc::new(EmptyLibraryCandidateSource), ), SelectiveVerifyFailurePlanExecutor { - environments: environments.clone(), facts: facts.clone(), recovery_root: recovery_root.clone(), failing_skill: "beta".to_string(), @@ -2256,7 +2252,6 @@ mod update_lifecycle { } struct StageFailurePlanExecutor { - environments: Arc, facts: RuntimePlanningFactSource, recovery_root: PathBuf, private_root: PathBuf, @@ -2286,9 +2281,9 @@ mod update_lifecycle { failing_skill: "beta".to_string(), private_root: self.private_root.clone(), }; - MutationCoordinator::new( + MutationCoordinator::from_phases( entries, - RuntimeLockCommitter::new(self.environments.clone()), + RuntimeLockCommitter::new(), self.facts.clone(), ) .execute(plan, cancellation) @@ -2750,7 +2745,6 @@ mod update_lifecycle { self.git_transport.clone(), ), StageFailurePlanExecutor { - environments: self.environments.clone(), facts: self.facts.clone(), recovery_root: self.recovery_root.clone(), private_root: self.project_path.join(".custom/skills"), From 1f72077d966746e4d588134cce164c3863b42706 Mon Sep 17 00:00:00 2001 From: Hccake Date: Fri, 4 Sep 2026 20:11:32 +0800 Subject: [PATCH 06/27] build(windows): package and verify the WSL worker --- .github/workflows/quality.yml | 14 +- .github/workflows/release.yml | 45 ++++- package.json | 2 + scripts/__tests__/check-docs.test.mjs | 4 +- scripts/__tests__/prepare-wsl-worker.test.mjs | 142 +++++++++++++++ scripts/__tests__/release-policy.test.mjs | 87 ++++++++- .../__tests__/skills-cli-eve-interop.test.mjs | 4 +- scripts/prepare-wsl-worker.mjs | 170 ++++++++++++++++++ scripts/verify-windows-worker-bundle.ps1 | 70 ++++++++ src-tauri/src/lib.rs | 6 + src-tauri/tauri.windows.conf.json | 8 + 11 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 scripts/__tests__/prepare-wsl-worker.test.mjs create mode 100644 scripts/prepare-wsl-worker.mjs create mode 100644 scripts/verify-windows-worker-bundle.ps1 create mode 100644 src-tauri/tauri.windows.conf.json diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 46687d59..f9d65a1b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,7 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - uses: dtolnay/rust-toolchain@1.88.0 - - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --all-targets + - run: cargo check --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets frontend: runs-on: ubuntu-22.04 @@ -63,13 +63,13 @@ jobs: uses: docker://rhysd/actionlint:1.7.12 shellcheck: - name: ShellCheck bundled WSL assets + name: ShellCheck WSL bootstrap runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 with: ref: ${{ inputs.target_sha }} - - name: Check bundled WSL operation scripts + - name: Check WSL bootstrap script run: >- docker run --rm --volume "$PWD:/work" @@ -92,7 +92,7 @@ jobs: shell: bash run: | mkdir -p ci-diagnostics - cargo fmt --manifest-path src-tauri/Cargo.toml -- --check 2>&1 | tee ci-diagnostics/fmt.log + cargo fmt --all --manifest-path src-tauri/Cargo.toml -- --check 2>&1 | tee ci-diagnostics/fmt.log - name: Upload Rust formatting diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -143,11 +143,11 @@ jobs: } | tee ci-diagnostics/toolchain.txt - name: Check all Rust targets shell: bash - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --all-targets 2>&1 | tee ci-diagnostics/check.log + run: cargo check --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets 2>&1 | tee ci-diagnostics/check.log - name: Lint all Rust targets if: ${{ always() }} shell: bash - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings 2>&1 | tee ci-diagnostics/clippy.log + run: cargo clippy --locked --workspace --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings 2>&1 | tee ci-diagnostics/clippy.log - name: Upload Rust static diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -196,7 +196,7 @@ jobs: } | tee ci-diagnostics/toolchain.txt - name: Run Rust tests shell: bash - run: cargo test --locked --manifest-path src-tauri/Cargo.toml 2>&1 | tee ci-diagnostics/test.log + run: cargo test --locked --workspace --manifest-path src-tauri/Cargo.toml 2>&1 | tee ci-diagnostics/test.log - name: Upload Rust test diagnostics if: ${{ always() }} uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54ada48e..eb0d9689 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,34 @@ jobs: with: target_sha: ${{ needs.validate.outputs.commit_sha }} + build-wsl-worker: + needs: [validate, quality] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + - uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + - name: Build WSL Worker artifact + run: node scripts/prepare-wsl-worker.mjs + - name: Verify WSL Worker artifact + run: node scripts/prepare-wsl-worker.mjs --verify src-tauri/target/wsl-worker/current + - name: Upload WSL Worker artifact + uses: actions/upload-artifact@v4 + with: + name: wsl-worker-${{ needs.validate.outputs.commit_sha }} + path: src-tauri/target/wsl-worker/current + if-no-files-found: error + retention-days: 7 + prepare-release: needs: [validate, quality] runs-on: ubuntu-22.04 @@ -184,7 +212,7 @@ jobs: echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" build-release: - needs: [validate, quality, prepare-release] + needs: [validate, quality, prepare-release, build-wsl-worker] permissions: contents: write strategy: @@ -229,6 +257,15 @@ jobs: with: workspaces: "./src-tauri -> target" - run: pnpm install --frozen-lockfile + - name: Download WSL Worker artifact + if: ${{ runner.os == 'Windows' }} + uses: actions/download-artifact@v4 + with: + name: wsl-worker-${{ needs.validate.outputs.commit_sha }} + path: src-tauri/target/wsl-worker/current + - name: Verify WSL Worker artifact + if: ${{ runner.os == 'Windows' }} + run: pnpm verify:wsl-worker - name: Build and upload release bundles uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: @@ -244,6 +281,12 @@ jobs: retryAttempts: 3 releaseAssetNamePattern: skill-deck_[version]_${{ matrix.asset_platform }}_[arch][setup][ext] args: ${{ needs.validate.outputs.prerelease == 'true' && matrix.prerelease_args || matrix.args }} + - name: Verify NSIS WSL Worker resource + if: ${{ runner.os == 'Windows' }} + run: pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind nsis + - name: Verify MSI WSL Worker resource + if: ${{ runner.os == 'Windows' && needs.validate.outputs.prerelease != 'true' }} + run: pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind msi verify-release: needs: [validate, prepare-release, build-release] diff --git a/package.json b/package.json index ba051dbe..18fcca62 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test:scripts": "node --test scripts/__tests__/*.test.mjs", "test:watch": "vitest", "preview": "vite preview", + "prepare:wsl-worker": "node scripts/prepare-wsl-worker.mjs", + "verify:wsl-worker": "node scripts/prepare-wsl-worker.mjs --verify src-tauri/target/wsl-worker/current", "sync-version": "node scripts/sync-version.mjs", "prepare": "husky" }, diff --git a/scripts/__tests__/check-docs.test.mjs b/scripts/__tests__/check-docs.test.mjs index 03682a8d..eee5519a 100644 --- a/scripts/__tests__/check-docs.test.mjs +++ b/scripts/__tests__/check-docs.test.mjs @@ -46,7 +46,7 @@ test("reports missing local files and anchors with their source locations", asyn }, async (root) => { const problems = await checkDocumentation(root, ["README.md", "docs/guide.md"]); assert.equal(problems.length, 2); - assert.match(problems[0], /README\.md:3: missing file: docs\/missing\.md/); - assert.match(problems[1], /README\.md:4: missing anchor: docs\/guide\.md#not-present/); + assert.match(problems[0], /README\.md:3: missing file: docs[\\/]missing\.md/); + assert.match(problems[1], /README\.md:4: missing anchor: docs[\\/]guide\.md#not-present/); }); }); diff --git a/scripts/__tests__/prepare-wsl-worker.test.mjs b/scripts/__tests__/prepare-wsl-worker.test.mjs new file mode 100644 index 00000000..8145f073 --- /dev/null +++ b/scripts/__tests__/prepare-wsl-worker.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildWorkerManifest, + parsePrepareArgs, + verifyWorkerArtifact, + workerBuildInvocation, +} from "../prepare-wsl-worker.mjs"; + +test("Linux builds the fixed musl worker target directly", () => { + const args = parsePrepareArgs([], "linux"); + + assert.deepEqual(args, { mode: "build", distro: undefined }); + assert.deepEqual( + workerBuildInvocation({ platform: "linux", distro: args.distro }), + { + command: "cargo", + args: [ + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + "wsl-environment-worker", + "--target", + "x86_64-unknown-linux-musl", + "--release", + ], + }, + ); +}); + +test("Windows builds through the selected WSL distribution", () => { + const args = parsePrepareArgs( + ["--", "--distro", "Ubuntu-24.04"], + "win32", + ); + assert.deepEqual(args, { mode: "build", distro: "Ubuntu-24.04" }); + + assert.deepEqual( + workerBuildInvocation({ + platform: "win32", + distro: args.distro, + linuxRepositoryRoot: "/mnt/c/code/skill-deck", + }), + { + command: "wsl.exe", + args: [ + "--distribution", + "Ubuntu-24.04", + "--cd", + "/mnt/c/code/skill-deck", + "--exec", + "/bin/sh", + "-lc", + 'exec "$@"', + "--", + "cargo", + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + "wsl-environment-worker", + "--target", + "x86_64-unknown-linux-musl", + "--release", + ], + }, + ); + assert.throws( + () => parsePrepareArgs([], "win32"), + /--distro is required on Windows/, + ); +}); + +test("worker manifest uses one SHA-256 value as its build identity", () => { + assert.deepEqual( + buildWorkerManifest("ab".repeat(32)), + { + buildId: `sha256:${"ab".repeat(32)}`, + sha256: `sha256:${"ab".repeat(32)}`, + target: "x86_64-unknown-linux-musl", + }, + ); +}); + +test("worker artifact verification rejects every mismatched contract field", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "skill-deck-worker-")); + const directory = path.join(root, "current"); + await mkdir(directory); + const digest = "87eba76e7f3164534045ba922e7770fb58bbd14ad732bbf5ba6f11cc56989e6e"; + const manifest = buildWorkerManifest(digest); + try { + await writeFile(path.join(directory, "worker"), "worker"); + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify(manifest)}\n`, + ); + assert.deepEqual(await verifyWorkerArtifact(directory), manifest); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify({ ...manifest, target: "x86_64-unknown-linux-gnu" })}\n`, + ); + await assert.rejects(() => verifyWorkerArtifact(directory), /target/); + + await writeFile(path.join(directory, "manifest.json"), "not-json"); + await assert.rejects(() => verifyWorkerArtifact(directory), /JSON/); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify({ ...manifest, buildId: `sha256:${"a".repeat(64)}` })}\n`, + ); + await assert.rejects(() => verifyWorkerArtifact(directory), /buildId/); + + await writeFile( + path.join(directory, "manifest.json"), + `${JSON.stringify(manifest)}\n`, + ); + await writeFile(path.join(directory, "worker"), "damaged"); + await assert.rejects(() => verifyWorkerArtifact(directory), /bytes/); + await rm(path.join(directory, "worker")); + await assert.rejects(() => verifyWorkerArtifact(directory)); + await writeFile(path.join(directory, "worker"), "worker"); + await rm(path.join(directory, "manifest.json")); + await assert.rejects(() => verifyWorkerArtifact(directory)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("verify mode is independent from the Windows build distro", () => { + assert.deepEqual( + parsePrepareArgs(["--verify", "C:\\artifact"], "win32"), + { mode: "verify", directory: "C:\\artifact" }, + ); +}); diff --git a/scripts/__tests__/release-policy.test.mjs b/scripts/__tests__/release-policy.test.mjs index fb463ec3..6507f869 100644 --- a/scripts/__tests__/release-policy.test.mjs +++ b/scripts/__tests__/release-policy.test.mjs @@ -19,6 +19,10 @@ const qualityWorkflowUrl = new URL( "../../.github/workflows/quality.yml", import.meta.url, ); +const windowsTauriConfigUrl = new URL( + "../../src-tauri/tauri.windows.conf.json", + import.meta.url, +); const releaseVerifierUrl = new URL("../verify-release-assets.mjs", import.meta.url); const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -71,6 +75,7 @@ test("CI and Release run the same quality workflow for an exact commit", async ( "validate", "quality", "prepare-release", + "build-wsl-worker", ]); for (const [jobName, job] of Object.entries(quality.jobs)) { @@ -109,19 +114,27 @@ test("quality workflow separates portable formatting, static checks, and tests", assert.equal(workflow.jobs["rust-static"].strategy["fail-fast"], false); assert.equal(workflow.jobs["rust-test"].strategy["fail-fast"], false); + const msrvCommands = workflow.jobs.msrv.steps + .map((step) => step.run ?? "") + .join("\n"); + const formatCommands = workflow.jobs["rust-format"].steps + .map((step) => step.run ?? "") + .join("\n"); const staticCommands = workflow.jobs["rust-static"].steps .map((step) => step.run ?? "") .join("\n"); const testCommands = workflow.jobs["rust-test"].steps .map((step) => step.run ?? "") .join("\n"); - assert.match(staticCommands, /cargo check[^\n]*--locked[^\n]*--all-targets/); + assert.match(msrvCommands, /cargo check[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets/); + assert.match(formatCommands, /cargo fmt[^\n]*--all[^\n]*-- --check/); + assert.match(staticCommands, /cargo check[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets/); assert.match( staticCommands, - /cargo clippy[^\n]*--locked[^\n]*--all-targets[^\n]*-- -D warnings/, + /cargo clippy[^\n]*--locked[^\n]*--workspace[^\n]*--all-targets[^\n]*-- -D warnings/, ); assert.doesNotMatch(staticCommands, /cargo test/); - assert.match(testCommands, /cargo test[^\n]*--locked/); + assert.match(testCommands, /cargo test[^\n]*--locked[^\n]*--workspace/); assert.doesNotMatch(testCommands, /cargo (?:check|clippy)/); const clippyStep = workflow.jobs["rust-static"].steps.find((step) => @@ -183,7 +196,12 @@ test("release workflow prepares one draft and lets tauri-action upload each plat assert.ok(workflow.jobs["prepare-release"]); assert.ok(workflow.jobs["verify-release"]); assert.equal(workflow.jobs["verify-release"].permissions.contents, "write"); - assert.deepEqual(build.needs, ["validate", "quality", "prepare-release"]); + assert.deepEqual(build.needs, [ + "validate", + "quality", + "prepare-release", + "build-wsl-worker", + ]); assert.equal(build.permissions.contents, "write"); assert.doesNotMatch( commands, @@ -214,6 +232,67 @@ test("release workflow prepares one draft and lets tauri-action upload each plat assert.equal(workflow.jobs.aggregate, undefined); }); +test("Windows bundles the exact Linux Worker artifact as Tauri resources", async () => { + const [workflow, windowsConfig] = await Promise.all([ + readWorkflow(workflowUrl), + readFile(windowsTauriConfigUrl, "utf8").then(JSON.parse), + ]); + assert.deepEqual(windowsConfig.bundle.resources, { + "target/wsl-worker/current/worker": "wsl-worker/current/worker", + "target/wsl-worker/current/manifest.json": "wsl-worker/current/manifest.json", + }); + assert.equal(windowsConfig.bundle.externalBin, undefined); + + const worker = workflow.jobs["build-wsl-worker"]; + assert.deepEqual(worker.needs, ["validate", "quality"]); + assert.equal(worker["runs-on"], "ubuntu-22.04"); + const checkout = worker.steps.find((step) => + step.uses?.startsWith("actions/checkout@") + ); + assert.equal(checkout.with.ref, "${{ needs.validate.outputs.commit_sha }}"); + const upload = worker.steps.find( + (step) => step.uses === "actions/upload-artifact@v4", + ); + assert.equal( + upload.with.name, + "wsl-worker-${{ needs.validate.outputs.commit_sha }}", + ); + assert.equal(upload.with.path, "src-tauri/target/wsl-worker/current"); + + const build = workflow.jobs["build-release"]; + const download = build.steps.find( + (step) => step.uses === "actions/download-artifact@v4", + ); + assert.equal(download.if, "${{ runner.os == 'Windows' }}"); + assert.equal( + download.with.name, + "wsl-worker-${{ needs.validate.outputs.commit_sha }}", + ); + assert.equal(download.with.path, "src-tauri/target/wsl-worker/current"); + const verify = build.steps.find((step) => step.run === "pnpm verify:wsl-worker"); + assert.equal(verify.if, "${{ runner.os == 'Windows' }}"); + + const actionIndex = build.steps.findIndex((step) => + step.uses?.startsWith("tauri-apps/tauri-action@") + ); + const nsisIndex = build.steps.findIndex( + (step) => step.run === "pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind nsis", + ); + const msiIndex = build.steps.findIndex( + (step) => step.run === "pwsh -NoProfile -File scripts/verify-windows-worker-bundle.ps1 -BundleKind msi", + ); + assert.ok(nsisIndex > actionIndex); + assert.ok(msiIndex > actionIndex); + assert.equal( + build.steps[nsisIndex].if, + "${{ runner.os == 'Windows' }}", + ); + assert.equal( + build.steps[msiIndex].if, + "${{ runner.os == 'Windows' && needs.validate.outputs.prerelease != 'true' }}", + ); +}); + test("release workflow omits MSI for prereleases and retains it for stable releases", async () => { const workflow = await readWorkflow(workflowUrl); const build = workflow.jobs["build-release"]; diff --git a/scripts/__tests__/skills-cli-eve-interop.test.mjs b/scripts/__tests__/skills-cli-eve-interop.test.mjs index 800b014e..551e6c66 100644 --- a/scripts/__tests__/skills-cli-eve-interop.test.mjs +++ b/scripts/__tests__/skills-cli-eve-interop.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { EOL, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; @@ -216,7 +216,7 @@ test("replays Eve placement when updating from an offline Git source", async (t) for (const target of [undefined, "builder"]) { assert.equal( await readFile(join(eveSkillPath(fixture.project, target), "payload.txt"), "utf8"), - "version two\n", + `version two${EOL}`, ); } }); diff --git a/scripts/prepare-wsl-worker.mjs b/scripts/prepare-wsl-worker.mjs new file mode 100644 index 00000000..d87813aa --- /dev/null +++ b/scripts/prepare-wsl-worker.mjs @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const WORKER_TARGET = "x86_64-unknown-linux-musl"; +const WORKER_PACKAGE = "wsl-environment-worker"; +const BUILD_ARGS = [ + "build", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + WORKER_PACKAGE, + "--target", + WORKER_TARGET, + "--release", +]; + +export function parsePrepareArgs(argv, platform = process.platform) { + if (argv[0] === "--") argv = argv.slice(1); + if (argv[0] === "--verify" && argv.length === 2) { + return { mode: "verify", directory: argv[1] }; + } + let distro; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== "--distro" || !argv[index + 1]) { + throw new Error(`unknown or incomplete argument: ${argv[index]}`); + } + distro = argv[index + 1]; + index += 1; + } + if (platform === "win32" && !distro) { + throw new Error("--distro is required on Windows"); + } + return { mode: "build", distro }; +} + +export function workerBuildInvocation({ + platform, + distro, + linuxRepositoryRoot, +}) { + if (platform === "linux") { + return { command: "cargo", args: BUILD_ARGS }; + } + if (platform === "win32" && distro && linuxRepositoryRoot) { + return { + command: "wsl.exe", + args: [ + "--distribution", + distro, + "--cd", + linuxRepositoryRoot, + "--exec", + "/bin/sh", + "-lc", + 'exec "$@"', + "--", + "cargo", + ...BUILD_ARGS, + ], + }; + } + throw new Error(`unsupported worker build platform: ${platform}`); +} + +export function buildWorkerManifest(hexDigest) { + const sha256 = `sha256:${hexDigest}`; + return { buildId: sha256, sha256, target: WORKER_TARGET }; +} + +export async function verifyWorkerArtifact(directory) { + const worker = await readFile(path.join(directory, "worker")); + const manifest = JSON.parse( + await readFile(path.join(directory, "manifest.json"), "utf8"), + ); + if (manifest.target !== WORKER_TARGET) { + throw new Error(`unsupported WSL worker target: ${manifest.target}`); + } + if (manifest.buildId !== manifest.sha256) { + throw new Error("WSL worker buildId does not match sha256"); + } + const actual = `sha256:${createHash("sha256").update(worker).digest("hex")}`; + if (manifest.sha256 !== actual) { + throw new Error("WSL worker bytes do not match manifest sha256"); + } + return manifest; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: options.encoding, + stdio: options.encoding ? ["ignore", "pipe", "inherit"] : "inherit", + cwd: options.cwd, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} exited with status ${result.status}`); + } + return result.stdout?.trim(); +} + +async function prepare() { + const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); + const args = parsePrepareArgs(process.argv.slice(2)); + if (args.mode === "verify") { + await verifyWorkerArtifact(path.resolve(repositoryRoot, args.directory)); + return; + } + const { distro } = args; + const linuxRepositoryRoot = + process.platform === "win32" + ? run( + "wsl.exe", + [ + "--distribution", + distro, + "--exec", + "wslpath", + "-a", + "-u", + repositoryRoot, + ], + { encoding: "utf8" }, + ) + : undefined; + const invocation = workerBuildInvocation({ + platform: process.platform, + distro, + linuxRepositoryRoot, + }); + run(invocation.command, invocation.args, { cwd: repositoryRoot }); + + const builtWorker = path.join( + repositoryRoot, + "src-tauri", + "target", + WORKER_TARGET, + "release", + WORKER_PACKAGE, + ); + const outputDirectory = path.join( + repositoryRoot, + "src-tauri", + "target", + "wsl-worker", + "current", + ); + const outputWorker = path.join(outputDirectory, "worker"); + await mkdir(outputDirectory, { recursive: true }); + await copyFile(builtWorker, outputWorker); + await chmod(outputWorker, 0o755); + const digest = createHash("sha256") + .update(await readFile(outputWorker)) + .digest("hex"); + await writeFile( + path.join(outputDirectory, "manifest.json"), + `${JSON.stringify(buildWorkerManifest(digest), null, 2)}\n`, + ); + await verifyWorkerArtifact(outputDirectory); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + await prepare(); +} diff --git a/scripts/verify-windows-worker-bundle.ps1 b/scripts/verify-windows-worker-bundle.ps1 new file mode 100644 index 00000000..a30769db --- /dev/null +++ b/scripts/verify-windows-worker-bundle.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('nsis', 'msi')] + [string]$BundleKind +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$bundleDirectory = Join-Path $repositoryRoot "src-tauri\target\release\bundle\$BundleKind" +$pattern = if ($BundleKind -eq 'nsis') { '*.exe' } else { '*.msi' } +$packages = @(Get-ChildItem -LiteralPath $bundleDirectory -Filter $pattern -File) +if ($packages.Count -ne 1) { + throw "Expected one $BundleKind package in $bundleDirectory, found $($packages.Count)" +} + +function Assert-WorkerArtifact { + param([Parameter(Mandatory = $true)][string]$Root) + + $directories = @(Get-ChildItem -LiteralPath $Root -Filter manifest.json -File -Recurse | + Where-Object { + $_.Directory.Name -eq 'current' -and + $_.Directory.Parent.Name -eq 'wsl-worker' -and + (Test-Path -LiteralPath (Join-Path $_.Directory.FullName 'worker') -PathType Leaf) + } | + ForEach-Object { $_.Directory.FullName }) + if ($directories.Count -ne 1) { + throw "Expected one bundled WSL Worker resource, found $($directories.Count)" + } + $global:LASTEXITCODE = 0 + & node (Join-Path $repositoryRoot 'scripts\prepare-wsl-worker.mjs') --verify $directories[0] + if ($LASTEXITCODE -ne 0) { + throw 'Bundled WSL Worker artifact verification failed' + } +} + +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "skill-deck-worker-bundle-$([Guid]::NewGuid().ToString('N'))" +try { + if ($BundleKind -eq 'msi') { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $msiArguments = @( + '/a', + "`"$($packages[0].FullName)`"", + '/qn', + "TARGETDIR=`"$temporaryRoot`"" + ) + $process = Start-Process -FilePath msiexec.exe -ArgumentList $msiArguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "MSI administrative extraction failed with exit code $($process.ExitCode)" + } + Assert-WorkerArtifact -Root $temporaryRoot + } + else { + $process = Start-Process -FilePath $packages[0].FullName -ArgumentList @('/S', "/D=$temporaryRoot") -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "NSIS installation failed with exit code $($process.ExitCode)" + } + Assert-WorkerArtifact -Root $temporaryRoot + } +} +finally { + if ($BundleKind -eq 'nsis') { + $uninstaller = Join-Path $temporaryRoot 'uninstall.exe' + if (Test-Path -LiteralPath $uninstaller -PathType Leaf) { + Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null + } + } + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e8a4e8c..70927c8d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,11 +111,17 @@ pub fn run() { let payload_cache_root = app.path().app_cache_dir()?.join("payload-sessions"); let recovery_root = app.path().app_local_data_dir()?.join("recovery"); let library_root = crate::core::get_skill_library_root()?; + #[cfg(target_os = "windows")] + let worker_artifact_directory = + Some(app.path().resource_dir()?.join("wsl-worker/current")); + #[cfg(not(target_os = "windows"))] + let worker_artifact_directory = None; let runtime = RuntimeServiceGraph::new( &payload_cache_root, recovery_root, library_root, agent_registry.clone(), + worker_artifact_directory, )?; let environments = runtime.wsl_arc(); let maintenance = runtime.maintenance().clone(); diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..01258fa9 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,8 @@ +{ + "bundle": { + "resources": { + "target/wsl-worker/current/worker": "wsl-worker/current/worker", + "target/wsl-worker/current/manifest.json": "wsl-worker/current/manifest.json" + } + } +} From 18c526094f8a18df18e954b0f582b047d8b6c2c3 Mon Sep 17 00:00:00 2001 From: Hccake Date: Sun, 6 Sep 2026 18:47:26 +0800 Subject: [PATCH 07/27] refactor(storage): unify atomic document persistence Use bounded document snapshots and conditional publication for Native and WSL storage. Preserve write phase and publication state until the owning transaction decides how to recover. Migrate configuration, custom Agents, project records, source evidence and lock storage to the shared primitive, retaining original data on read failures and interrupted migrations. --- .../environment-engine/src/atomic_document.rs | 467 ++++++++++++++++++ .../crates/environment-engine/src/document.rs | 121 ++--- .../crates/environment-engine/src/lib.rs | 3 +- .../environment-engine/tests/document.rs | 42 +- .../wsl-environment-worker/src/library.rs | 2 + .../crates/wsl-environment-worker/src/main.rs | 53 +- .../application/agent_selection_history.rs | 2 +- src-tauri/src/application/agents.rs | 6 +- src-tauri/src/application/skills.rs | 4 +- src-tauri/src/application/source_evidence.rs | 43 ++ .../src/application/source_evidence_state.rs | 112 ++++- src-tauri/src/core/app_config.rs | 174 ++++++- src-tauri/src/core/custom_agent_repository.rs | 83 +++- src-tauri/src/core/lock_repository.rs | 29 +- src-tauri/src/core/projects.rs | 264 ++++++++-- src-tauri/src/environment/lock_io.rs | 116 ++--- .../src/environment/native/atomic_file.rs | 205 ++++++-- src-tauri/src/environment/planning.rs | 2 +- src-tauri/src/environment/project_service.rs | 2 +- src-tauri/src/environment/wsl.rs | 155 ++++-- .../environment/wsl/operations/atomic_file.rs | 123 ++++- .../environment/wsl/operations/document.rs | 174 +++++-- src-tauri/src/runtime/planning_facts.rs | 35 +- src-tauri/src/runtime/skill_libraries.rs | 27 +- src-tauri/src/runtime/wsl_source.rs | 2 +- src-tauri/src/storage/atomic_document.rs | 84 +++- src-tauri/src/storage/lock_plan.rs | 113 ++++- 27 files changed, 1994 insertions(+), 449 deletions(-) create mode 100644 src-tauri/crates/environment-engine/src/atomic_document.rs diff --git a/src-tauri/crates/environment-engine/src/atomic_document.rs b/src-tauri/crates/environment-engine/src/atomic_document.rs new file mode 100644 index 00000000..25177110 --- /dev/null +++ b/src-tauri/crates/environment-engine/src/atomic_document.rs @@ -0,0 +1,467 @@ +//! Small synchronous document-publication mechanics. +//! +//! The caller owns path authorization, schema validation, parent creation and +//! read/modify/write serialization. This module never deletes a `.bak`, follows +//! a recovery policy, or treats an I/O error after publication as a rollback. +//! Snapshot comparison is optimistic: it is not a lock for cooperating or +//! uncooperative external processes. Directory transactions remain separate. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicationState { + NotPublished, + PublishedUnconfirmed, + OutcomeUnknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WritePhase { + Preparing, + Writing, + BeforePublish, + Publishing, + Confirming, +} + +#[derive(Debug)] +pub struct AtomicWriteError { + pub publication: PublicationState, + pub phase: WritePhase, + source: io::Error, + conflict: bool, +} + +impl AtomicWriteError { + pub fn is_conflict(&self) -> bool { + self.conflict + } + + fn io(phase: WritePhase, publication: PublicationState, source: io::Error) -> Self { + Self { + publication, + phase, + source, + conflict: false, + } + } + + fn conflict() -> Self { + Self { + publication: PublicationState::NotPublished, + phase: WritePhase::BeforePublish, + source: io::Error::other("document changed since its snapshot was read"), + conflict: true, + } + } +} + +impl std::fmt::Display for AtomicWriteError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "document {:?} failed ({:?}): {}", + self.phase, self.publication, self.source + ) + } +} + +impl std::error::Error for AtomicWriteError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +#[derive(Clone, Copy)] +enum Expectation<'a> { + Any, + Snapshot(Option<&'a [u8]>), +} + +/// Publish complete bytes in an existing, authorized parent directory. +/// A successful return includes file sync and, on Unix, parent-directory sync. +/// Other platforms retain their existing directory-sync limitations. +pub fn replace(path: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> { + replace_with_hook(path, Expectation::Any, bytes, |_| Ok(())) +} + +/// The expected bytes are the bytes used to compute this update, not a new +/// snapshot acquired inside a save wrapper. `None` means expected missing. +pub fn replace_if_unchanged( + path: &Path, + expected: Option<&[u8]>, + bytes: &[u8], +) -> Result<(), AtomicWriteError> { + replace_with_hook(path, Expectation::Snapshot(expected), bytes, |_| Ok(())) +} + +/// Remove a document only while it still matches the observed bytes. A +/// successful return includes parent-directory sync where the platform +/// supports it. `None` means the caller observed the document as missing. +pub fn remove_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<(), AtomicWriteError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .filter(|_| path.file_name().is_some()) + .ok_or_else(|| { + AtomicWriteError::io( + WritePhase::Preparing, + PublicationState::NotPublished, + io::Error::new( + io::ErrorKind::InvalidInput, + "document needs an explicit parent and file name", + ), + ) + })?; + check_expected(path, Expectation::Snapshot(expected))?; + if expected.is_none() { + return Ok(()); + } + check_expected(path, Expectation::Snapshot(expected))?; + fs::remove_file(path).map_err(|error| { + AtomicWriteError::io( + WritePhase::Publishing, + PublicationState::OutcomeUnknown, + error, + ) + })?; + sync_directory(parent).map_err(|error| { + AtomicWriteError::io( + WritePhase::Confirming, + PublicationState::PublishedUnconfirmed, + error, + ) + }) +} + +fn replace_with_hook( + path: &Path, + expected: Expectation<'_>, + bytes: &[u8], + mut hook: impl FnMut(WritePhase) -> io::Result<()>, +) -> Result<(), AtomicWriteError> { + let before = |phase, error| AtomicWriteError::io(phase, PublicationState::NotPublished, error); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .filter(|_| path.file_name().is_some()) + .ok_or_else(|| { + before( + WritePhase::Preparing, + io::Error::new( + io::ErrorKind::InvalidInput, + "document needs an explicit parent and file name", + ), + ) + })?; + check_expected(path, expected)?; + let mut temporary = + TemporaryDocument::create(parent).map_err(|error| before(WritePhase::Preparing, error))?; + hook(WritePhase::Writing).map_err(|error| before(WritePhase::Writing, error))?; + let file = temporary + .file + .as_mut() + .expect("new temporary has its file handle"); + file.write_all(bytes) + .map_err(|error| before(WritePhase::Writing, error))?; + file.sync_all() + .map_err(|error| before(WritePhase::Writing, error))?; + // Closing before rename also works on platforms with restrictive sharing. + drop(temporary.file.take()); + hook(WritePhase::BeforePublish).map_err(|error| before(WritePhase::BeforePublish, error))?; + check_expected(path, expected)?; + let temporary_path = temporary + .path + .as_ref() + .expect("unpublished temporary has a path"); + fs::rename(temporary_path, path).map_err(|error| { + AtomicWriteError::io( + WritePhase::Publishing, + // A failed namespace operation on some storage may have taken effect. + // Do not promise rollback solely from its error return. + PublicationState::OutcomeUnknown, + error, + ) + })?; + temporary.path = None; + hook(WritePhase::Confirming) + .and_then(|()| sync_directory(parent)) + .map_err(|error| { + AtomicWriteError::io( + WritePhase::Confirming, + PublicationState::PublishedUnconfirmed, + error, + ) + }) +} + +fn check_expected(path: &Path, expected: Expectation<'_>) -> Result<(), AtomicWriteError> { + let Expectation::Snapshot(expected) = expected else { + return Ok(()); + }; + let limit = expected.map_or(0, <[u8]>::len); + match read_optional_bounded(path, limit) { + Ok(current) if current.as_deref() == expected => Ok(()), + Ok(_) => Err(AtomicWriteError::conflict()), + // The observed file grew beyond the old snapshot, so it cannot match. + Err(error) if error.kind() == io::ErrorKind::InvalidData => { + Err(AtomicWriteError::conflict()) + } + Err(error) => Err(AtomicWriteError::io( + WritePhase::BeforePublish, + PublicationState::NotPublished, + error, + )), + } +} + +/// No truncated bytes are returned as a valid document snapshot. The caller +/// chooses a resource-specific limit; zero permits only an empty file. +pub fn read_optional_bounded(path: &Path, limit: usize) -> io::Result>> { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "document is not a regular file", + )); + } + let bound = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + let mut bytes = Vec::new(); + file.take(bound).read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "document exceeds its read limit", + )); + } + Ok(Some(bytes)) +} + +#[cfg(unix)] +pub fn sync_directory(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +pub fn sync_directory(_parent: &Path) -> io::Result<()> { + // Do not claim Unix directory durability on Windows or other platforms. + Ok(()) +} + +struct TemporaryDocument { + path: Option, + file: Option, +} + +impl TemporaryDocument { + fn create(parent: &Path) -> io::Result { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)? + .as_nanos(); + for _ in 0..64 { + let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + ".skill-deck-document-{}-{nonce:x}-{sequence:x}", + std::process::id(), + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&path) { + Ok(file) => { + return Ok(Self { + path: Some(path), + file: Some(file), + }) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "cannot allocate a unique document temporary", + )) + } +} + +impl Drop for TemporaryDocument { + fn drop(&mut self) { + drop(self.file.take()); + if let Some(path) = &self.path { + let _ = fs::remove_file(path); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn complete_document_replacement_preserves_unrelated_backup() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + let backup = root.path().join("document.json.bak"); + fs::write(&target, b"old").unwrap(); + fs::write(&backup, b"last-known-good").unwrap(); + replace(&target, b"new").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read(&backup).unwrap(), b"last-known-good"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2); + } + + #[test] + fn prepublication_failure_keeps_old_bytes_and_removes_only_own_temporary() { + for phase in [WritePhase::Writing, WritePhase::BeforePublish] { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook(&target, Expectation::Any, b"new", |at| { + if at == phase { + Err(io::Error::other("injected")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + } + + #[test] + fn failure_after_publish_is_not_reported_as_unchanged() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook(&target, Expectation::Any, b"new", |phase| { + if phase == WritePhase::Confirming { + Err(io::Error::other("injected directory sync failure")) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.publication, PublicationState::PublishedUnconfirmed); + assert_eq!(error.phase, WritePhase::Confirming); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1); + } + + #[test] + fn conditional_replace_rechecks_the_original_bytes_at_publish() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"old").unwrap(); + let error = replace_with_hook( + &target, + Expectation::Snapshot(Some(b"old")), + b"new", + |phase| { + if phase == WritePhase::BeforePublish { + fs::write(&target, b"external")?; + } + Ok(()) + }, + ) + .unwrap_err(); + assert!(error.is_conflict()); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"external"); + } + + #[test] + fn missing_and_empty_are_different_snapshots() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + assert_eq!(read_optional_bounded(&target, 0).unwrap(), None); + replace_if_unchanged(&target, None, b"").unwrap(); + assert_eq!(read_optional_bounded(&target, 0).unwrap(), Some(Vec::new())); + assert!(replace_if_unchanged(&target, None, b"unexpected") + .unwrap_err() + .is_conflict()); + replace_if_unchanged(&target, Some(b""), b"next").unwrap(); + } + + #[test] + fn conditional_remove_preserves_a_document_changed_after_observation() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"external").unwrap(); + + let error = remove_if_unchanged(&target, Some(b"observed")).unwrap_err(); + + assert!(error.is_conflict()); + assert_eq!(error.publication, PublicationState::NotPublished); + assert_eq!(fs::read(&target).unwrap(), b"external"); + } + + #[test] + fn conditional_remove_distinguishes_missing_from_present() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + remove_if_unchanged(&target, None).unwrap(); + fs::write(&target, b"created-later").unwrap(); + + assert!(remove_if_unchanged(&target, None) + .unwrap_err() + .is_conflict()); + remove_if_unchanged(&target, Some(b"created-later")).unwrap(); + assert!(!target.exists()); + } + + #[test] + fn bounded_read_rejects_oversize_instead_of_returning_truncated_snapshot() { + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + fs::write(&target, b"12345").unwrap(); + assert_eq!( + read_optional_bounded(&target, 5).unwrap(), + Some(b"12345".to_vec()) + ); + assert_eq!( + read_optional_bounded(&target, 4).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert!(read_optional_bounded(root.path(), 100).is_err()); + } + + #[test] + fn primitive_does_not_create_missing_parent_directories() { + let root = tempdir().unwrap(); + let parent = root.path().join("not-created"); + let error = replace(&parent.join("document.json"), b"new").unwrap_err(); + assert_eq!(error.publication, PublicationState::NotPublished); + assert!(!parent.exists()); + } + + #[cfg(unix)] + #[test] + fn private_temporary_does_not_publish_world_readable_data() { + use std::os::unix::fs::PermissionsExt; + let root = tempdir().unwrap(); + let target = root.path().join("document.json"); + replace(&target, b"private").unwrap(); + assert_eq!( + fs::metadata(target).unwrap().permissions().mode() & 0o077, + 0 + ); + } +} diff --git a/src-tauri/crates/environment-engine/src/document.rs b/src-tauri/crates/environment-engine/src/document.rs index 300d231a..b5156953 100644 --- a/src-tauri/crates/environment-engine/src/document.rs +++ b/src-tauri/crates/environment-engine/src/document.rs @@ -1,6 +1,8 @@ use std::fmt; use std::path::PathBuf; +use crate::atomic_document::{PublicationState, WritePhase}; + #[cfg(target_os = "linux")] use std::fs; #[cfg(target_os = "linux")] @@ -59,7 +61,10 @@ impl fmt::Display for DocumentError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DocumentWriteError { UnsupportedPlatform, - Io, + Io { + phase: WritePhase, + publication: PublicationState, + }, Conflict, InvalidTarget, } @@ -68,7 +73,9 @@ impl fmt::Display for DocumentWriteError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnsupportedPlatform => formatter.write_str("Linux document write is unavailable"), - Self::Io => formatter.write_str("document write failed"), + Self::Io { phase, publication } => { + write!(formatter, "document write failed ({phase:?}, {publication:?})") + } Self::Conflict => formatter.write_str("document changed since it was read"), Self::InvalidTarget => formatter.write_str("document target is not a regular file"), } @@ -79,15 +86,17 @@ pub fn write_document_atomic( path: &std::path::Path, expected_revision: Option<&str>, bytes: &[u8], + max_current_bytes: usize, ) -> Result { - write_document_platform(path, expected_revision, bytes) + write_document_platform(path, expected_revision, bytes, max_current_bytes) } pub fn remove_document_if_revision( path: &std::path::Path, expected_revision: Option<&str>, + max_current_bytes: usize, ) -> Result<(), DocumentWriteError> { - remove_document_platform(path, expected_revision) + remove_document_platform(path, expected_revision, max_current_bytes) } #[cfg(not(target_os = "linux"))] @@ -95,6 +104,7 @@ fn write_document_platform( _path: &std::path::Path, _expected_revision: Option<&str>, _bytes: &[u8], + _max_current_bytes: usize, ) -> Result { Err(DocumentWriteError::UnsupportedPlatform) } @@ -103,6 +113,7 @@ fn write_document_platform( fn remove_document_platform( _path: &std::path::Path, _expected_revision: Option<&str>, + _max_current_bytes: usize, ) -> Result<(), DocumentWriteError> { Err(DocumentWriteError::UnsupportedPlatform) } @@ -112,10 +123,8 @@ fn write_document_platform( path: &std::path::Path, expected_revision: Option<&str>, bytes: &[u8], + max_current_bytes: usize, ) -> Result { - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - if !path.is_absolute() || path.file_name().is_none() { return Err(DocumentWriteError::InvalidTarget); } @@ -123,51 +132,41 @@ fn write_document_platform( Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err(DocumentWriteError::InvalidTarget) } - Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), + Ok(_) => crate::atomic_document::read_optional_bounded(path, max_current_bytes).map_err( + |_| DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }, + )?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(_) => return Err(DocumentWriteError::Io), + Err(_) => { + return Err(DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }) + } }; let current_revision = current.as_deref().map(document_revision); if current_revision.as_deref() != expected_revision { return Err(DocumentWriteError::Conflict); } let parent = path.parent().ok_or(DocumentWriteError::InvalidTarget)?; - fs::create_dir_all(parent).map_err(|_| DocumentWriteError::Io)?; - let temporary = parent.join(format!(".skill-deck-document-{}", std::process::id())); - if fs::symlink_metadata(&temporary).is_ok() { - return Err(DocumentWriteError::Io); - } - let result = (|| { - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) - .map_err(|_| DocumentWriteError::Io)?; - fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)) - .map_err(|_| DocumentWriteError::Io)?; - file.write_all(bytes).map_err(|_| DocumentWriteError::Io)?; - file.sync_all().map_err(|_| DocumentWriteError::Io)?; - let latest = match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(DocumentWriteError::InvalidTarget) + fs::create_dir_all(parent).map_err(|_| DocumentWriteError::Io { + phase: WritePhase::Preparing, + publication: PublicationState::NotPublished, + })?; + crate::atomic_document::replace_if_unchanged(path, current.as_deref(), bytes).map_err( + |error| { + if error.is_conflict() { + DocumentWriteError::Conflict + } else { + DocumentWriteError::Io { + phase: error.phase, + publication: error.publication, + } } - Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(_) => return Err(DocumentWriteError::Io), - }; - if latest.as_deref().map(document_revision).as_deref() != expected_revision { - return Err(DocumentWriteError::Conflict); - } - fs::rename(&temporary, path).map_err(|_| DocumentWriteError::Io)?; - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| DocumentWriteError::Io)?; - Ok::<_, DocumentWriteError>(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&temporary); - } - result?; + }, + )?; Ok(document_revision(bytes)) } @@ -175,6 +174,7 @@ fn write_document_platform( fn remove_document_platform( path: &std::path::Path, expected_revision: Option<&str>, + max_current_bytes: usize, ) -> Result<(), DocumentWriteError> { if !path.is_absolute() || path.file_name().is_none() { return Err(DocumentWriteError::InvalidTarget); @@ -183,9 +183,19 @@ fn remove_document_platform( Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err(DocumentWriteError::InvalidTarget) } - Ok(_) => Some(fs::read(path).map_err(|_| DocumentWriteError::Io)?), + Ok(_) => crate::atomic_document::read_optional_bounded(path, max_current_bytes).map_err( + |_| DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }, + )?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(_) => return Err(DocumentWriteError::Io), + Err(_) => { + return Err(DocumentWriteError::Io { + phase: WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + }) + } }; if current.as_deref().map(document_revision).as_deref() != expected_revision { return Err(DocumentWriteError::Conflict); @@ -193,15 +203,16 @@ fn remove_document_platform( if current.is_none() { return Ok(()); } - let latest = fs::read(path).map_err(|_| DocumentWriteError::Io)?; - if document_revision(&latest) != expected_revision.unwrap_or_default() { - return Err(DocumentWriteError::Conflict); - } - fs::remove_file(path).map_err(|_| DocumentWriteError::Io)?; - let parent = path.parent().ok_or(DocumentWriteError::InvalidTarget)?; - fs::File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|_| DocumentWriteError::Io) + crate::atomic_document::remove_if_unchanged(path, current.as_deref()).map_err(|error| { + if error.is_conflict() { + DocumentWriteError::Conflict + } else { + DocumentWriteError::Io { + phase: error.phase, + publication: error.publication, + } + } + }) } #[cfg(target_os = "linux")] diff --git a/src-tauri/crates/environment-engine/src/lib.rs b/src-tauri/crates/environment-engine/src/lib.rs index 6634f754..5ff9505f 100644 --- a/src-tauri/crates/environment-engine/src/lib.rs +++ b/src-tauri/crates/environment-engine/src/lib.rs @@ -1,7 +1,8 @@ #![forbid(unsafe_code)] -//! Shared Linux filesystem mechanics used by Native Linux and the WSL Worker. +//! Shared filesystem mechanics, with platform-specific Linux operations for the WSL Worker. +pub mod atomic_document; pub mod directory; pub mod document; pub mod entry; diff --git a/src-tauri/crates/environment-engine/tests/document.rs b/src-tauri/crates/environment-engine/tests/document.rs index dfa8548a..ea4411dc 100644 --- a/src-tauri/crates/environment-engine/tests/document.rs +++ b/src-tauri/crates/environment-engine/tests/document.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "linux")] +use environment_engine::atomic_document::PublicationState; use environment_engine::document::{ read_documents, remove_document_if_revision, write_document_atomic, DocumentQuery, DocumentRequest, DocumentState, DocumentWriteError, @@ -12,13 +13,14 @@ fn conditional_document_write_replaces_a_file_and_returns_its_revision() { let path = temp.path().join("state/projects.json"); let bytes = br#"{"projects":[]}"#; - let revision = write_document_atomic(&path, None, bytes).unwrap(); + let revision = write_document_atomic(&path, None, bytes, 1024).unwrap(); let expected = format!("sha256:{:x}", sha2::Sha256::digest(bytes)); assert_eq!(revision, expected); assert_eq!(std::fs::read(&path).unwrap(), bytes); let replacement = br#"{"projects":["demo"]}"#; - let replacement_revision = write_document_atomic(&path, Some(&revision), replacement).unwrap(); + let replacement_revision = + write_document_atomic(&path, Some(&revision), replacement, 1024).unwrap(); assert_eq!( replacement_revision, format!("sha256:{:x}", sha2::Sha256::digest(replacement)) @@ -30,15 +32,15 @@ fn conditional_document_write_replaces_a_file_and_returns_its_revision() { fn conditional_document_remove_preserves_a_changed_target() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("applications/project.json"); - let revision = write_document_atomic(&path, None, b"current").unwrap(); + let revision = write_document_atomic(&path, None, b"current", 1024).unwrap(); assert_eq!( - remove_document_if_revision(&path, Some("sha256:wrong")).unwrap_err(), + remove_document_if_revision(&path, Some("sha256:wrong"), 1024).unwrap_err(), DocumentWriteError::Conflict ); assert_eq!(std::fs::read(&path).unwrap(), b"current"); - remove_document_if_revision(&path, Some(&revision)).unwrap(); + remove_document_if_revision(&path, Some(&revision), 1024).unwrap(); assert!(!path.exists()); } @@ -50,7 +52,7 @@ fn conditional_document_write_rejects_a_changed_file_without_overwriting_it() { std::fs::write(&path, b"old").unwrap(); assert_eq!( - write_document_atomic(&path, Some("sha256:wrong"), b"new").unwrap_err(), + write_document_atomic(&path, Some("sha256:wrong"), b"new", 1024).unwrap_err(), DocumentWriteError::Conflict ); assert_eq!(std::fs::read(&path).unwrap(), b"old"); @@ -66,7 +68,7 @@ fn conditional_document_write_rejects_directory_and_symlink_targets() { let directory = temp.path().join("state"); std::fs::create_dir(&directory).unwrap(); assert_eq!( - write_document_atomic(&directory, None, b"new").unwrap_err(), + write_document_atomic(&directory, None, b"new", 1024).unwrap_err(), DocumentWriteError::InvalidTarget ); let target = temp.path().join("target"); @@ -74,7 +76,7 @@ fn conditional_document_write_rejects_directory_and_symlink_targets() { let link = temp.path().join("link"); std::os::unix::fs::symlink(&target, &link).unwrap(); assert_eq!( - write_document_atomic(&link, None, b"new").unwrap_err(), + write_document_atomic(&link, None, b"new", 1024).unwrap_err(), DocumentWriteError::InvalidTarget ); } @@ -115,3 +117,27 @@ fn optional_documents_are_bounded_and_isolated() { assert_eq!(response.facts[2].state, DocumentState::NotFile); assert_eq!(response.total_content_bytes, 4); } + +#[test] +fn conditional_writes_and_removals_reject_an_oversized_current_document() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(32).unwrap(); + + assert_eq!( + write_document_atomic(&path, None, b"new", 16).unwrap_err(), + DocumentWriteError::Io { + phase: environment_engine::atomic_document::WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + } + ); + assert_eq!( + remove_document_if_revision(&path, None, 16).unwrap_err(), + DocumentWriteError::Io { + phase: environment_engine::atomic_document::WritePhase::BeforePublish, + publication: PublicationState::NotPublished, + } + ); + assert_eq!(std::fs::metadata(path).unwrap().len(), 32); +} diff --git a/src-tauri/crates/wsl-environment-worker/src/library.rs b/src-tauri/crates/wsl-environment-worker/src/library.rs index 5d60c8df..be2df2d6 100644 --- a/src-tauri/crates/wsl-environment-worker/src/library.rs +++ b/src-tauri/crates/wsl-environment-worker/src/library.rs @@ -58,12 +58,14 @@ impl LibraryManager { || request.deadline_millis > MAX_REQUEST_DEADLINE_MILLIS || !valid_component(&request.operation_id) || request.catalog_bytes.is_empty() + || request.catalog_bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize { return Err(LibraryError::InvalidRequest); } let catalog = CatalogWrite { expected_revision: request.expected_catalog_revision, bytes: request.catalog_bytes, + max_current_bytes: environment_protocol::MAX_DOCUMENT_BYTES as usize, }; match request.action { LibraryOperationAction::SaveCatalog { library_ids } => { diff --git a/src-tauri/crates/wsl-environment-worker/src/main.rs b/src-tauri/crates/wsl-environment-worker/src/main.rs index 4f2db1f2..5cc011e9 100644 --- a/src-tauri/crates/wsl-environment-worker/src/main.rs +++ b/src-tauri/crates/wsl-environment-worker/src/main.rs @@ -1732,6 +1732,7 @@ async fn execute_document_write( PathBuf::from(preparation.path).as_path(), preparation.expected_revision.as_deref(), &bytes, + environment_protocol::MAX_DOCUMENT_BYTES as usize, ) }) .await @@ -1756,8 +1757,13 @@ async fn execute_document_write( .await .map_err(|error| error.to_string())?; } - Err(error) => { - let _ = error; + Err(environment_engine::document::DocumentWriteError::Io { phase, publication }) => { + let code = document_write_error_code(publication); + send_error(&writer, request_id, code, document_write_phase(phase)) + .await + .map_err(|error| error.to_string())?; + } + Err(environment_engine::document::DocumentWriteError::UnsupportedPlatform) => { send_error(&writer, request_id, "documentWriteFailed", "documentWrite") .await .map_err(|error| error.to_string())?; @@ -1879,6 +1885,7 @@ async fn execute_document_remove( environment_engine::document::remove_document_if_revision( PathBuf::from(request.path).as_path(), request.expected_revision.as_deref(), + environment_protocol::MAX_DOCUMENT_BYTES as usize, ) }) .await @@ -1901,13 +1908,51 @@ async fn execute_document_remove( .await .map_err(|error| error.to_string())? } - Err(_) => send_error(&writer, request_id, "documentWriteFailed", "documentRemove") + Err(environment_engine::document::DocumentWriteError::Io { phase, publication }) => { + send_error( + &writer, + request_id, + document_write_error_code(publication), + document_write_phase(phase), + ) .await - .map_err(|error| error.to_string())?, + .map_err(|error| error.to_string())? + } + Err(environment_engine::document::DocumentWriteError::UnsupportedPlatform) => { + send_error(&writer, request_id, "documentWriteFailed", "documentRemove") + .await + .map_err(|error| error.to_string())? + } } Ok(request_id) } +fn document_write_error_code( + publication: environment_engine::atomic_document::PublicationState, +) -> &'static str { + match publication { + environment_engine::atomic_document::PublicationState::NotPublished => { + "documentWriteFailed" + } + environment_engine::atomic_document::PublicationState::PublishedUnconfirmed => { + "documentPublishedUnconfirmed" + } + environment_engine::atomic_document::PublicationState::OutcomeUnknown => { + "documentOutcomeUnknown" + } + } +} + +fn document_write_phase(phase: environment_engine::atomic_document::WritePhase) -> &'static str { + match phase { + environment_engine::atomic_document::WritePhase::Preparing => "preparing", + environment_engine::atomic_document::WritePhase::Writing => "writing", + environment_engine::atomic_document::WritePhase::BeforePublish => "beforePublish", + environment_engine::atomic_document::WritePhase::Publishing => "publishing", + environment_engine::atomic_document::WritePhase::Confirming => "confirming", + } +} + async fn send_library_error( writer: &ProtocolWriter, request_id: u64, diff --git a/src-tauri/src/application/agent_selection_history.rs b/src-tauri/src/application/agent_selection_history.rs index 95e177da..81788cdd 100644 --- a/src-tauri/src/application/agent_selection_history.rs +++ b/src-tauri/src/application/agent_selection_history.rs @@ -26,7 +26,7 @@ pub async fn get_last_selected_agents( EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); let workspace = wsl.workspace(&distro_name)?; - wsl.with_session_retry(&distro_name, move |session| { + wsl.with_session_read_retry(&distro_name, move |session| { let context = context.clone(); let workspace = workspace.clone(); async move { diff --git a/src-tauri/src/application/agents.rs b/src-tauri/src/application/agents.rs index d7772e25..983c7fe8 100644 --- a/src-tauri/src/application/agents.rs +++ b/src-tauri/src/application/agents.rs @@ -465,7 +465,7 @@ pub async fn list_agents( let retry_context = context.clone(); let service = agent_registry; environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = retry_context.clone(); async move { @@ -561,7 +561,7 @@ pub async fn validate_custom_agent_draft( let workspace = environment_registry.workspace(&distro_name)?; let retry_context = context.clone(); environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = retry_context.clone(); let preview_snapshot = preview_snapshot.clone(); @@ -696,7 +696,7 @@ pub async fn preview_custom_agent_delete( let distro_name = distro_name.clone(); let workspace = environment_registry.workspace(&distro_name)?; environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); let context = context.clone(); let definition = definition.clone(); diff --git a/src-tauri/src/application/skills.rs b/src-tauri/src/application/skills.rs index c1ad6234..a2bb0fdc 100644 --- a/src-tauri/src/application/skills.rs +++ b/src-tauri/src/application/skills.rs @@ -358,7 +358,7 @@ pub async fn list_skills( let retry_runtime = runtime.clone(); let workspace = environment_registry.workspace(&distro_name)?; let (resolved, plan) = environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let context = retry_context.clone(); let runtime = retry_runtime.clone(); let workspace = workspace.clone(); @@ -379,7 +379,7 @@ pub async fn list_skills( let result = project_skill_snapshot(&plan, snapshot, &runtime)?; let retry_resolved = resolved.clone(); let (lock_bytes, lock_kind) = environment_registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let resolved = retry_resolved.clone(); let workspace = workspace.clone(); async move { diff --git a/src-tauri/src/application/source_evidence.rs b/src-tauri/src/application/source_evidence.rs index e0cd2178..6ed2a3e7 100644 --- a/src-tauri/src/application/source_evidence.rs +++ b/src-tauri/src/application/source_evidence.rs @@ -2530,6 +2530,49 @@ mod tests { assert_eq!(detector.calls(), 2); } + #[tokio::test] + async fn credential_cleanup_converges_after_an_unconfirmed_publish() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("state/update-check.json"); + let now = Arc::new(AtomicU64::new(1_000)); + let detector = Arc::new(ScriptedDetector::new([EvidenceDetectionOutcome::Failed( + EvidenceDetectionFailure { + reason: EvidenceFailureReason::AuthenticationRequired, + message: "token required".to_string(), + retry_at_epoch_ms: None, + provider_cooldown: false, + }, + )])); + let coordinator = persistent_coordinator(detector, now.clone(), &path); + coordinator + .check( + request("acme/private", EvidenceCheckMode::Force), + CancellationSignal::default(), + ) + .await + .unwrap(); + coordinator + .inner + .state_file + .as_ref() + .expect("state file") + .set_post_publish_failure(true); + + coordinator.clear_native_github_auth_suppression().unwrap(); + + let operation = EnvironmentEvidenceKey::new(&EnvironmentRef::Native, &key("acme/private")); + assert!(!state(&coordinator.inner) + .unwrap() + .attempts + .contains_key(&operation)); + drop(coordinator); + let restarted = persistent_coordinator(Arc::new(ScriptedDetector::new([])), now, &path); + assert!(!state(&restarted.inner) + .unwrap() + .attempts + .contains_key(&operation)); + } + #[tokio::test] async fn source_repair_clears_only_the_exact_environment_and_source() { let detector = Arc::new(ScriptedDetector::new([ diff --git a/src-tauri/src/application/source_evidence_state.rs b/src-tauri/src/application/source_evidence_state.rs index f59637f3..ce7cf997 100644 --- a/src-tauri/src/application/source_evidence_state.rs +++ b/src-tauri/src/application/source_evidence_state.rs @@ -1,5 +1,4 @@ use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::{ @@ -7,16 +6,19 @@ use std::sync::{ Arc, }; -use tempfile::NamedTempFile; use uuid::Uuid; use crate::error::AppError; +const MAX_SOURCE_EVIDENCE_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; + #[derive(Clone)] pub(crate) struct SourceEvidenceStateFile { path: PathBuf, #[cfg(test)] fail_writes: Arc, + #[cfg(test)] + fail_after_publish: Arc, } impl SourceEvidenceStateFile { @@ -25,15 +27,17 @@ impl SourceEvidenceStateFile { path, #[cfg(test)] fail_writes: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + fail_after_publish: Arc::new(AtomicBool::new(false)), } } pub(crate) fn read_optional(&self) -> Result>, AppError> { - match fs::read(&self.path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error.into()), - } + environment_engine::atomic_document::read_optional_bounded( + &self.path, + MAX_SOURCE_EVIDENCE_BYTES, + ) + .map_err(Into::into) } pub(crate) fn write_atomic(&self, bytes: &[u8]) -> Result<(), AppError> { @@ -44,12 +48,55 @@ impl SourceEvidenceStateFile { let parent = self.parent()?; fs::create_dir_all(parent)?; - let mut temporary = NamedTempFile::new_in(parent)?; - temporary.write_all(bytes)?; - temporary.write_all(b"\n")?; - temporary.as_file_mut().sync_all()?; - temporary.persist(&self.path).map_err(|error| error.error)?; - sync_parent(parent) + let mut document = Vec::with_capacity(bytes.len().saturating_add(1)); + document.extend_from_slice(bytes); + document.push(b'\n'); + let result = environment_engine::atomic_document::replace(&self.path, &document) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map(|_| ()); + #[cfg(test)] + let result = match result { + Ok(()) if self.fail_after_publish.load(Ordering::SeqCst) => { + Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "forced update-check state confirmation failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }) + } + result => result, + }; + match result { + Ok(()) => Ok(()), + Err(failure) + if failure.publication + == crate::storage::atomic_document::PublicationState::PublishedUnconfirmed => + { + log::warn!( + "来源证据状态已经发布,但持久化确认失败;当前进程继续使用新状态: {}", + failure.error + ); + Ok(()) + } + Err(failure) + if failure.publication + == crate::storage::atomic_document::PublicationState::OutcomeUnknown => + { + match environment_engine::atomic_document::read_optional_bounded( + &self.path, + document.len(), + ) { + Ok(Some(current)) if current == document => { + log::warn!("来源证据状态发布结果未知,重新读取后确认新状态已经生效"); + Ok(()) + } + _ => Err(failure.error), + } + } + Err(failure) => Err(failure.error), + } } #[cfg(test)] @@ -57,6 +104,11 @@ impl SourceEvidenceStateFile { self.fail_writes.store(fail, Ordering::SeqCst); } + #[cfg(test)] + pub(crate) fn set_post_publish_failure(&self, fail: bool) { + self.fail_after_publish.store(fail, Ordering::SeqCst); + } + pub(crate) fn quarantine(&self, now_epoch_ms: u64) -> Result { let file_name = self .path @@ -83,13 +135,35 @@ impl SourceEvidenceStateFile { } } -#[cfg(unix)] fn sync_parent(parent: &Path) -> Result<(), AppError> { - fs::File::open(parent)?.sync_all()?; - Ok(()) + environment_engine::atomic_document::sync_directory(parent).map_err(Into::into) } -#[cfg(not(unix))] -fn sync_parent(_parent: &Path) -> Result<(), AppError> { - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oversized_source_evidence_state_is_rejected_before_deserialization() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("source-evidence.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let state = SourceEvidenceStateFile::new(path.clone()); + + let error = match state.read_optional() { + Err(error) => error, + Ok(_) => panic!("oversized source evidence must be rejected"), + }; + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/app_config.rs b/src-tauri/src/core/app_config.rs index 27387855..b9e9c76b 100644 --- a/src-tauri/src/core/app_config.rs +++ b/src-tauri/src/core/app_config.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; static CONFIG_UPDATE_LOCK: Mutex<()> = Mutex::new(()); +const MAX_CONFIG_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; fn get_skill_deck_home() -> Result { let home = dirs::home_dir().ok_or(AppError::Path { @@ -45,30 +46,49 @@ fn update_config_at_path( let _guard = CONFIG_UPDATE_LOCK .lock() .expect("config update lock poisoned"); - let mut config = read_config_from_path(path)?; + let mut config = read_config_document(path, true)?; update(&mut config); write_config_to_path(&config, path)?; Ok(config) } fn read_config_from_path(path: &Path) -> Result { - if !path.exists() { - log::info!("配置文件不存在,返回默认配置"); - return Ok(SkillDeckConfig::default()); - } + read_config_document(path, false) +} - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - log::warn!("读取配置文件失败: {},返回默认配置", e); +fn read_config_document(path: &Path, require_writable: bool) -> Result { + let bytes = + match environment_engine::atomic_document::read_optional_bounded(path, MAX_CONFIG_BYTES) { + Ok(Some(bytes)) => bytes, + Ok(None) => return Ok(SkillDeckConfig::default()), + Err(error) if require_writable => return Err(error.into()), + Err(error) => { + log::warn!("读取配置文件失败,运行时使用默认配置,原文件保持不变: {error}"); + return Ok(SkillDeckConfig::default()); + } + }; + let content = match String::from_utf8(bytes) { + Ok(content) => content, + Err(error) if require_writable => { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error).into()); + } + Err(error) => { + log::warn!("配置文件编码无效,运行时使用默认配置,原文件保持不变: {error}"); + return Ok(SkillDeckConfig::default()); + } + }; + let mut value: serde_json::Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(error) if require_writable => { + return Err(AppError::ConfigurationCorrupted { + message: format!("configuration must be repaired before saving: {error}"), + }); + } + Err(error) => { + log::warn!("配置损坏,运行时使用默认配置,原文件保持不变: {error}"); return Ok(SkillDeckConfig::default()); } }; - - let mut value: serde_json::Value = serde_json::from_str(&content).unwrap_or_else(|e| { - log::warn!("解析配置文件失败: {},返回默认配置", e); - serde_json::json!({}) - }); let network_proxy = value .as_object_mut() .and_then(|object| object.remove("networkProxy")) @@ -83,15 +103,31 @@ fn read_config_from_path(path: &Path) -> Result { .map_err(|error| format!("code={}", error.code())) }) }, - ) - .unwrap_or_else(|error| { + ); + let network_proxy = match network_proxy { + Ok(settings) => settings, + Err(error) if require_writable => { + return Err(AppError::ConfigurationCorrupted { + message: format!("network proxy settings must be repaired before saving: {error}"), + }); + } + Err(error) => { log::warn!("代理设置无效,使用直接连接: {}", error); NetworkProxySettings::default() - }); - let mut config: SkillDeckConfig = serde_json::from_value(value).unwrap_or_else(|e| { - log::warn!("解析应用配置失败: {},返回默认配置", e); - SkillDeckConfig::default() - }); + } + }; + let mut config: SkillDeckConfig = match serde_json::from_value(value) { + Ok(config) => config, + Err(error) if require_writable => { + return Err(AppError::ConfigurationCorrupted { + message: format!("configuration must be repaired before saving: {error}"), + }); + } + Err(error) => { + log::warn!("解析配置失败,运行时使用默认配置,原文件保持不变: {error}"); + SkillDeckConfig::default() + } + }; config.network_proxy = network_proxy; Ok(config) } @@ -102,7 +138,7 @@ fn write_config_to_path(config: &SkillDeckConfig, path: &Path) -> Result<(), App } let content = serde_json::to_string_pretty(config)?; - fs::write(path, content)?; + crate::environment::native::atomic_file::write_native_atomic(path, content.as_bytes())?; log::info!("配置已保存到: {:?}", path); Ok(()) @@ -117,6 +153,7 @@ mod tests { use super::{ get_skill_library_root, read_config_from_path, update_config_at_path, write_config_to_path, }; + use crate::error::AppError; use crate::models::{NativeGitProxySettings, ProxyMode, SkillDeckConfig}; use tempfile::tempdir; @@ -190,6 +227,30 @@ mod tests { assert!(config.network_proxy.wsl_git.is_empty()); } + #[test] + fn invalid_network_settings_are_not_overwritten_by_an_unrelated_update() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + let original = br#"{ + "projects": ["/must-survive"], + "networkProxy": { + "mode": "system", + "customProxyUrl": "http://127.0.0.1:7890" + } + }"#; + fs::write(&path, original).expect("invalid network settings"); + + let result = update_config_at_path(&path, |config| { + config.git_clone_timeout_secs = 60; + }); + + assert!(matches!( + result, + Err(AppError::ConfigurationCorrupted { .. }) + )); + assert_eq!(fs::read(path).expect("original config"), original); + } + #[test] fn config_updates_are_serialized_across_read_modify_write() { let temp = tempdir().expect("tempdir"); @@ -237,4 +298,73 @@ mod tests { assert_eq!(config.git_clone_timeout_secs, 300); assert!(config.wsl_integration_enabled); } + #[test] + fn corrupt_config_can_degrade_for_reading_but_is_not_overwritten_by_an_update() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let original = b"{\"projects\": [\"/keep-me\"]"; + fs::write(&path, original).unwrap(); + assert!(read_config_from_path(&path).is_ok()); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn invalid_typed_config_is_preserved_instead_of_saved_as_defaults() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let original = br#"{"projects":"not-an-array"}"#; + fs::write(&path, original).unwrap(); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn an_unreadable_document_is_not_treated_as_missing_during_update() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + fs::create_dir(&path).unwrap(); + assert!(update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).is_err()); + assert!(path.is_dir()); + } + + #[test] + fn first_config_update_initializes_missing_file_without_removing_a_backup() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let backup = temp.path().join("config.json.bak"); + fs::write(&backup, b"owned-by-another-maintenance-flow").unwrap(); + update_config_at_path(&path, |config| config.git_clone_timeout_secs = 60).unwrap(); + assert_eq!( + read_config_from_path(&path).unwrap().git_clone_timeout_secs, + 60 + ); + assert_eq!( + fs::read(backup).unwrap(), + b"owned-by-another-maintenance-flow" + ); + } + + #[test] + fn oversized_config_is_rejected_before_it_can_be_saved() { + let temp = tempdir().unwrap(); + let path = temp.path().join("config.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + + let error = update_config_at_path(&path, |config| { + config.git_clone_timeout_secs = 60; + }) + .unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/custom_agent_repository.rs b/src-tauri/src/core/custom_agent_repository.rs index 82e9b446..c0cf9846 100644 --- a/src-tauri/src/core/custom_agent_repository.rs +++ b/src-tauri/src/core/custom_agent_repository.rs @@ -1,9 +1,6 @@ -use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use serde_json::{Map, Value}; -use tempfile::NamedTempFile; use super::agent_definition::{AgentFieldError, AgentId, CustomAgentDefinition}; use super::agent_settings::CustomAgentRecord; @@ -11,6 +8,7 @@ use super::app_config::get_config_path; use crate::error::AppError; pub const CUSTOM_AGENT_SCHEMA_VERSION: u32 = 1; +const MAX_CUSTOM_AGENT_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CustomAgentFile { @@ -79,14 +77,20 @@ impl CustomAgentRepository { Err(ReadFailure::UnsupportedSchema(version)) => Err(unsupported_schema_error(version)), Err(ReadFailure::Io(error)) => Err(error), Err(ReadFailure::Missing) => match read_file(&self.backup_path()) { - Ok(file) => Ok(file), + Ok(file) => { + log::warn!("Custom Agent repository loaded its last valid backup"); + Ok(file) + } Err(ReadFailure::UnsupportedSchema(version)) => { Err(unsupported_schema_error(version)) } Err(_) => Ok(CustomAgentFile::default()), }, Err(ReadFailure::Corrupt(primary_error)) => match read_file(&self.backup_path()) { - Ok(file) => Ok(file), + Ok(file) => { + log::warn!("Custom Agent repository loaded its last valid backup"); + Ok(file) + } Err(ReadFailure::UnsupportedSchema(version)) => { Err(unsupported_schema_error(version)) } @@ -100,21 +104,19 @@ impl CustomAgentRepository { return Err(unsupported_schema_error(file.schema_version as u64)); } validate_records(&file.records)?; + let bytes = serialize_file(file)?; - match fs::read(&self.path) { - Ok(existing) => match parse_file(&existing) { + if let Some(existing) = read_document_bytes(&self.path)? { + match parse_file(&existing) { Ok(_) => write_atomic(&self.backup_path(), &existing)?, Err(ReadFailure::UnsupportedSchema(version)) => { return Err(unsupported_schema_error(version)); } Err(ReadFailure::Io(error)) => return Err(error), Err(ReadFailure::Missing | ReadFailure::Corrupt(_)) => {} - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), + } } - let bytes = serialize_file(file)?; write_atomic(&self.path, &bytes) } @@ -373,13 +375,18 @@ fn serialize_file(file: &CustomAgentFile) -> Result, AppError> { } fn read_file(path: &Path) -> Result { - match fs::read(path) { - Ok(bytes) => parse_file(&bytes), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(ReadFailure::Missing), - Err(error) => Err(ReadFailure::Io(error.into())), + match read_document_bytes(path) { + Ok(Some(bytes)) => parse_file(&bytes), + Ok(None) => Err(ReadFailure::Missing), + Err(error) => Err(ReadFailure::Io(error)), } } +fn read_document_bytes(path: &Path) -> Result>, AppError> { + environment_engine::atomic_document::read_optional_bounded(path, MAX_CUSTOM_AGENT_BYTES) + .map_err(Into::into) +} + fn parse_file(bytes: &[u8]) -> Result { let root: Value = serde_json::from_slice(bytes).map_err(|error| ReadFailure::Corrupt(error.into()))?; @@ -452,14 +459,9 @@ fn deserialization_error(error: &serde_json::Error) -> AgentFieldError { } fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp = NamedTempFile::new_in(parent)?; - temp.write_all(bytes)?; - temp.flush()?; - temp.as_file().sync_all()?; - temp.persist(path).map_err(|error| error.error)?; - Ok(()) + // The repository owns its one-generation backup. The shared publication + // mechanism must neither rotate nor delete it. + crate::environment::native::atomic_file::write_native_atomic(path, bytes) } enum ReadFailure { @@ -1141,4 +1143,39 @@ mod tests { assert_eq!(repository.load().expect("reload repository"), saved); assert_eq!(fs::read_to_string(marker).expect("read marker"), "keep me"); } + #[test] + fn backup_publication_failure_does_not_overwrite_the_primary() { + let temp = tempdir().unwrap(); + let repository = CustomAgentRepository::new(temp.path().join("custom-agents.json")); + let first = file(vec![CustomAgentRecord::valid(definition("first-agent"))]); + repository.save(&first).unwrap(); + let before = fs::read(repository.path()).unwrap(); + // A directory is a deterministic, cross-platform publication failure. + fs::create_dir(repository.backup_path()).unwrap(); + let second = file(vec![CustomAgentRecord::valid(definition("second-agent"))]); + assert!(repository.save(&second).is_err()); + assert_eq!(fs::read(repository.path()).unwrap(), before); + assert!(repository.backup_path().is_dir()); + } + + #[test] + fn oversized_repository_is_rejected_as_io_without_modifying_it() { + let temp = tempdir().unwrap(); + let path = temp.path().join("custom-agents.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let repository = CustomAgentRepository::new(path.clone()); + + let error = repository.load().unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/core/lock_repository.rs b/src-tauri/src/core/lock_repository.rs index 22441f8b..27a4075d 100644 --- a/src-tauri/src/core/lock_repository.rs +++ b/src-tauri/src/core/lock_repository.rs @@ -8,6 +8,7 @@ use crate::core::lossless_lock::{ use crate::environment::lock_io::EnvironmentLockIo; use crate::environment::types::ResourceLocator; use crate::error::AppError; +use crate::storage::atomic_document::{DocumentSnapshot, DocumentWriteFailure}; pub struct LockTarget { pub primary: ResourceLocator, @@ -32,8 +33,7 @@ pub struct LockTransaction<'a> { struct ParsedLockSnapshot { document: LosslessLockDocument, - primary_revision: Option, - primary_generation: Option, + primary: DocumentSnapshot, } impl LockRepository { @@ -52,26 +52,23 @@ impl LockRepository { &self, target: &LockTarget, ) -> Result { - let primary = self.io.read_optional_snapshot(&target.primary).await?; - if let Some(bytes) = primary.bytes { + let primary = self.io.observe(&target.primary).await?; + if let Some(bytes) = primary.bytes.as_deref() { return Ok(ParsedLockSnapshot { - document: LosslessLockDocument::parse(&bytes)?, - primary_revision: primary.revision, - primary_generation: primary.generation, + document: LosslessLockDocument::parse(bytes)?, + primary, }); } let Some(legacy) = target.legacy.as_ref() else { return Ok(ParsedLockSnapshot { document: LosslessLockDocument::empty(target.schema), - primary_revision: None, - primary_generation: primary.generation, + primary, }); }; let Some(bytes) = self.io.read_optional(legacy).await? else { return Ok(ParsedLockSnapshot { document: LosslessLockDocument::empty(target.schema), - primary_revision: None, - primary_generation: primary.generation, + primary, }); }; let document = LosslessLockDocument::parse(&bytes)?; @@ -80,8 +77,7 @@ impl LockRepository { LockSchema::Global => document, LockSchema::Project => convert_legacy_project_document(document)?, }, - primary_revision: None, - primary_generation: primary.generation, + primary, }) } @@ -140,13 +136,14 @@ impl LockTransaction<'_> { } repository .io - .write_if_revision( + .replace( &target.primary, - latest_snapshot.primary_generation, - latest_snapshot.primary_revision, + latest_snapshot.primary, latest.to_pretty_bytes()?, ) .await + .map(|_| ()) + .map_err(DocumentWriteFailure::into_error) } fn require_root_snapshot(&self, field: &str) -> Result<&LockRootSnapshot, AppError> { diff --git a/src-tauri/src/core/projects.rs b/src-tauri/src/core/projects.rs index b4bf1a1b..53ea1b47 100644 --- a/src-tauri/src/core/projects.rs +++ b/src-tauri/src/core/projects.rs @@ -1,17 +1,16 @@ use std::collections::HashSet; use std::fs; -use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Mutex; use serde::{Deserialize, Serialize}; -use tempfile::NamedTempFile; use uuid::Uuid; use crate::environment::types::RegisteredProject; use crate::error::AppError; const PROJECTS_SCHEMA_VERSION: u32 = 1; +const MAX_PROJECT_DOCUMENT_BYTES: usize = environment_protocol::MAX_DOCUMENT_BYTES as usize; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ProjectPathSemantics { @@ -168,15 +167,28 @@ impl ProjectsStore { } pub fn read(&self) -> Result, AppError> { - if !self.path.exists() { - return Ok(Vec::new()); + let content = match read_document_bytes(&self.path)? { + Some(content) => content, + None => return Ok(Vec::new()), + }; + // Read the version before deserializing an older shape. Future versions + // may have changed the remaining fields and must never be rewritten. + let value: serde_json::Value = serde_json::from_slice(&content)?; + if value + .get("schemaVersion") + .and_then(serde_json::Value::as_u64) + != Some(u64::from(PROJECTS_SCHEMA_VERSION)) + { + return Err(AppError::ConfigurationReadOnly); } - let content = fs::read(&self.path)?; - let file: ProjectsFile = serde_json::from_slice(&content)?; + let file: ProjectsFile = serde_json::from_value(value)?; Ok(file.projects) } pub fn write(&self, projects: &[RegisteredProject]) -> Result<(), AppError> { + // Direct writes obey the same unreadable/corrupt/future-version guard as + // read/modify/write callers. The application still owns serialization. + self.read()?; let projects = deduplicate_projects(projects, self.semantics); let file = ProjectsFile { schema_version: PROJECTS_SCHEMA_VERSION, @@ -215,15 +227,20 @@ pub fn migrate_legacy_projects( config_path: &Path, projects_path: &Path, ) -> Result { - let store = ProjectsStore::new(projects_path.to_path_buf()); - if projects_path.is_file() { - return Ok(ProjectMigrationState::NotNeeded); - } - if !config_path.exists() { - return Ok(ProjectMigrationState::NotNeeded); - } + migrate_legacy_projects_with_before_config_commit(config_path, projects_path, || {}) +} - let mut config: serde_json::Value = serde_json::from_slice(&fs::read(config_path)?)?; +fn migrate_legacy_projects_with_before_config_commit( + config_path: &Path, + projects_path: &Path, + before_config_commit: impl FnOnce(), +) -> Result { + let store = ProjectsStore::new(projects_path.to_path_buf()); + let original = match read_document_bytes(config_path)? { + Some(bytes) => bytes, + None => return Ok(ProjectMigrationState::NotNeeded), + }; + let mut config: serde_json::Value = serde_json::from_slice(&original)?; let Some(root) = config.as_object_mut() else { return Err(AppError::Json { message: "config root must be a JSON object".to_string(), @@ -244,23 +261,58 @@ pub fn migrate_legacy_projects( return Ok(ProjectMigrationState::NotNeeded); } - let projects = legacy_paths - .into_iter() - .map(|native_path| RegisteredProject { - id: Uuid::new_v4().to_string(), - native_path: normalize_native_path(&native_path, store.semantics), - display_name: None, - order: None, - suppress_cross_storage_warning: false, - }) - .collect::>(); - - store.write(&projects)?; + let registry_exists = match fs::symlink_metadata(projects_path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + }; + if registry_exists { + // Retrying after the new registry was published must not allocate new + // project IDs. Only finish cleanup when every old path is represented. + let current = store.read()?; + let keys = current + .iter() + .map(|project| normalize_project_path(&project.native_path, store.semantics).key) + .collect::>(); + if legacy_paths + .iter() + .any(|path| !keys.contains(&normalize_project_path(path, store.semantics).key)) + { + return Err(AppError::ConfigurationCorrupted { + message: "existing projects do not contain all legacy project paths; original config retained".to_string(), + }); + } + } else { + let projects = legacy_paths + .into_iter() + .map(|native_path| RegisteredProject { + id: Uuid::new_v4().to_string(), + native_path: normalize_native_path(&native_path, store.semantics), + display_name: None, + order: None, + suppress_cross_storage_warning: false, + }) + .collect::>(); + store.write(&projects)?; + } - let backup_path = config_backup_path(config_path); - fs::copy(config_path, backup_path)?; + // Preserve the exact source bytes. A partial backup cannot authorize + // removing the last legacy representation. + crate::environment::native::atomic_file::write_native_atomic( + &config_backup_path(config_path), + &original, + )?; root.remove("projects"); - atomic_write_json(config_path, &config)?; + before_config_commit(); + let mut updated = serde_json::to_vec_pretty(&config)?; + updated.push(b'\n'); + environment_engine::atomic_document::replace_if_unchanged( + config_path, + Some(&original), + &updated, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error)?; Ok(ProjectMigrationState::Succeeded) } @@ -401,14 +453,14 @@ fn normalize_windows_project_path(path: &str) -> NormalizedProjectPath { } fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<(), AppError> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent)?; - let mut temp = NamedTempFile::new_in(parent)?; - serde_json::to_writer_pretty(&mut temp, value)?; - temp.write_all(b"\n")?; - temp.as_file().sync_all()?; - temp.persist(path).map_err(|error| error.error)?; - Ok(()) + let mut bytes = serde_json::to_vec_pretty(value)?; + bytes.push(b'\n'); + crate::environment::native::atomic_file::write_native_atomic(path, &bytes) +} + +fn read_document_bytes(path: &Path) -> Result>, AppError> { + environment_engine::atomic_document::read_optional_bounded(path, MAX_PROJECT_DOCUMENT_BYTES) + .map_err(Into::into) } fn config_backup_path(config_path: &Path) -> PathBuf { @@ -424,7 +476,8 @@ mod tests { use tempfile::tempdir; use super::{ - add_project_binding, migrate_legacy_projects, ProjectMigrationRegistry, + add_project_binding, migrate_legacy_projects, + migrate_legacy_projects_with_before_config_commit, ProjectMigrationRegistry, ProjectMigrationState, ProjectPathSemantics, ProjectsStore, }; use crate::environment::types::RegisteredProject; @@ -589,6 +642,48 @@ mod tests { assert_eq!(config["projects"], json!(["/demo"])); } + #[test] + fn migration_does_not_overwrite_a_config_changed_after_observation() { + let temp = tempdir().unwrap(); + let config_path = temp.path().join("config.json"); + let projects_path = temp.path().join("projects.json"); + let original = serde_json::to_vec(&json!({ + "projects": ["/demo"], + "gitCloneTimeoutSecs": 120 + })) + .unwrap(); + fs::write(&config_path, original).unwrap(); + let externally_updated = serde_json::to_vec(&json!({ + "projects": ["/demo"], + "gitCloneTimeoutSecs": 300 + })) + .unwrap(); + + let result = + migrate_legacy_projects_with_before_config_commit(&config_path, &projects_path, || { + fs::write(&config_path, &externally_updated).unwrap() + }); + + assert!(matches!(result, Err(AppError::StaleTarget))); + assert_eq!(fs::read(&config_path).unwrap(), externally_updated); + let first_id = ProjectsStore::new(projects_path.clone()).read().unwrap()[0] + .id + .clone(); + + assert_eq!( + migrate_legacy_projects(&config_path, &projects_path).unwrap(), + ProjectMigrationState::Succeeded + ); + assert_eq!( + ProjectsStore::new(projects_path).read().unwrap()[0].id, + first_id + ); + let config: serde_json::Value = + serde_json::from_slice(&fs::read(config_path).unwrap()).unwrap(); + assert_eq!(config["gitCloneTimeoutSecs"], 300); + assert!(config.get("projects").is_none()); + } + #[test] fn failed_migration_state_blocks_native_projects_until_replaced() { let registry = ProjectMigrationRegistry::new(ProjectMigrationState::Failed { @@ -690,4 +785,97 @@ mod tests { assert_eq!(posix.project.native_path, "/"); assert_eq!(windows.project.native_path, "C:\\"); } + + #[test] + fn future_project_schema_is_read_only_even_for_direct_write() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + let original = br#"{"schemaVersion":2,"projects":[],"future":true}"#; + fs::write(&path, original).unwrap(); + let store = ProjectsStore::new(path.clone()); + assert!(matches!(store.read(), Err(AppError::ConfigurationReadOnly))); + assert!(matches!( + store.write(&[]), + Err(AppError::ConfigurationReadOnly) + )); + assert_eq!(fs::read(path).unwrap(), original); + } + + #[test] + fn corrupt_project_registry_is_not_replaced_with_an_empty_registry() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + fs::write(&path, b"{broken").unwrap(); + let store = ProjectsStore::new(path.clone()); + assert!(store.write(&[]).is_err()); + assert_eq!(fs::read(path).unwrap(), b"{broken"); + } + + #[test] + fn migration_retry_preserves_existing_project_ids_and_finishes_config_cleanup() { + let temp = tempdir().unwrap(); + let config = temp.path().join("config.json"); + let projects = temp.path().join("projects.json"); + let project_path = temp.path().join("project").to_string_lossy().into_owned(); + let store = ProjectsStore::new(projects.clone()); + let existing = store.add(project_path.clone()).unwrap().project; + // Simulate an interruption after projects.json was published but before + // the legacy config field was removed. This is not a new migration. + fs::write( + &config, + serde_json::to_vec(&json!({ + "projects": [project_path], "futureField": {"keep": true} + })) + .unwrap(), + ) + .unwrap(); + assert_eq!( + migrate_legacy_projects(&config, &projects).unwrap(), + ProjectMigrationState::Succeeded + ); + assert_eq!(store.read().unwrap()[0].id, existing.id); + let result: serde_json::Value = serde_json::from_slice(&fs::read(config).unwrap()).unwrap(); + assert!(result.get("projects").is_none()); + assert_eq!(result["futureField"], json!({"keep": true})); + } + + #[test] + fn migration_does_not_merge_unknown_partial_state_into_an_existing_registry() { + let temp = tempdir().unwrap(); + let config = temp.path().join("config.json"); + let projects = temp.path().join("projects.json"); + let store = ProjectsStore::new(projects.clone()); + store + .add(temp.path().join("new").to_string_lossy().into_owned()) + .unwrap(); + let original = serde_json::to_vec(&json!({ + "projects": [temp.path().join("old").to_string_lossy()] + })) + .unwrap(); + fs::write(&config, &original).unwrap(); + assert!(migrate_legacy_projects(&config, &projects).is_err()); + assert_eq!(fs::read(config).unwrap(), original); + assert_eq!(store.read().unwrap().len(), 1); + } + + #[test] + fn oversized_project_registry_is_rejected_before_deserialization() { + let temp = tempdir().unwrap(); + let path = temp.path().join("projects.json"); + let file = fs::File::create(&path).unwrap(); + file.set_len(u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1) + .unwrap(); + let store = ProjectsStore::new(path.clone()); + + let error = store.read().unwrap_err(); + + assert!(matches!( + error, + AppError::Io { ref message } if message.contains("exceeds its read limit") + )); + assert_eq!( + fs::metadata(path).unwrap().len(), + u64::from(environment_protocol::MAX_DOCUMENT_BYTES) + 1 + ); + } } diff --git a/src-tauri/src/environment/lock_io.rs b/src-tauri/src/environment/lock_io.rs index 76c17ce9..63a26698 100644 --- a/src-tauri/src/environment/lock_io.rs +++ b/src-tauri/src/environment/lock_io.rs @@ -1,15 +1,9 @@ use crate::environment::native::atomic_file::NativeAtomicDocumentIo; use crate::environment::types::ResourceLocator; +use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; -use sha2::{Digest, Sha256}; - -pub struct LockDocumentSnapshot { - pub bytes: Option>, - pub revision: Option, - pub generation: Option, -} +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure}; pub enum EnvironmentLockIo { Native, @@ -24,36 +18,43 @@ impl EnvironmentLockIo { &self, locator: &ResourceLocator, ) -> Result>, AppError> { - Ok(self.read_optional_snapshot(locator).await?.bytes) + Ok(self.observe(locator).await?.bytes) + } + + pub async fn observe(&self, locator: &ResourceLocator) -> Result { + match self { + Self::Native => { + NativeAtomicDocumentIo + .observe(locator, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + } + Self::ActiveWsl { session, workspace } => { + require_active_wsl_target(session, locator)?; + WslAtomicDocumentIo::from_active_session((**session).clone(), workspace.clone()) + .observe(locator, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + } + } } - pub async fn read_optional_snapshot( + pub async fn replace( &self, locator: &ResourceLocator, - ) -> Result { + expected: DocumentSnapshot, + bytes: Vec, + ) -> Result { match self { Self::Native => { - let bytes = NativeAtomicDocumentIo.read_optional(locator).await?; - let revision = bytes.as_deref().map(document_revision); - Ok(LockDocumentSnapshot { - bytes, - revision, - generation: None, - }) + NativeAtomicDocumentIo + .replace(locator, expected, bytes) + .await } Self::ActiveWsl { session, workspace } => { - require_active_wsl_target(session, locator)?; - let snapshot = workspace - .read_optional_document_snapshot_once( - locator.native_path.clone(), - environment_protocol::MAX_DOCUMENT_BYTES, - ) - .await?; - Ok(LockDocumentSnapshot { - bytes: snapshot.bytes, - revision: snapshot.revision, - generation: Some(snapshot.generation), - }) + require_active_wsl_target(session, locator) + .map_err(DocumentWriteFailure::not_published)?; + WslAtomicDocumentIo::from_active_session((**session).clone(), workspace.clone()) + .replace(locator, expected, bytes) + .await } } } @@ -73,59 +74,14 @@ impl EnvironmentLockIo { locator: &ResourceLocator, bytes: Vec, ) -> Result<(), AppError> { - match self { - Self::Native => NativeAtomicDocumentIo.write_atomic(locator, bytes).await, - Self::ActiveWsl { session, workspace } => { - let snapshot = self.read_optional_snapshot(locator).await?; - require_active_wsl_target(session, locator)?; - workspace - .write_document_atomic( - snapshot.generation.ok_or(AppError::StaleEnvironment)?, - locator.native_path.clone(), - snapshot.revision, - bytes, - ) - .await - .map(|_| ()) - } - } - } - - pub async fn write_if_revision( - &self, - locator: &ResourceLocator, - expected_generation: Option, - expected_revision: Option, - bytes: Vec, - ) -> Result<(), AppError> { - match self { - Self::Native => { - let current = NativeAtomicDocumentIo.read_optional(locator).await?; - if current.as_deref().map(document_revision) != expected_revision { - return Err(AppError::StaleTarget); - } - NativeAtomicDocumentIo.write_atomic(locator, bytes).await - } - Self::ActiveWsl { session, workspace } => { - require_active_wsl_target(session, locator)?; - workspace - .write_document_atomic( - expected_generation.ok_or(AppError::StaleEnvironment)?, - locator.native_path.clone(), - expected_revision, - bytes, - ) - .await - .map(|_| ()) - } - } + let snapshot = self.observe(locator).await?; + self.replace(locator, snapshot, bytes) + .await + .map(|_| ()) + .map_err(DocumentWriteFailure::into_error) } } -fn document_revision(bytes: &[u8]) -> String { - format!("sha256:{:x}", Sha256::digest(bytes)) -} - fn require_active_wsl_target( session: &WslSession, locator: &ResourceLocator, diff --git a/src-tauri/src/environment/native/atomic_file.rs b/src-tauri/src/environment/native/atomic_file.rs index 3095b6f3..e624ce23 100644 --- a/src-tauri/src/environment/native/atomic_file.rs +++ b/src-tauri/src/environment/native/atomic_file.rs @@ -1,59 +1,119 @@ use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use tempfile::NamedTempFile; +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::AppError; -use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; +use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure, IoFuture, +}; #[derive(Clone, Copy)] pub struct NativeAtomicDocumentIo; impl AtomicDocumentIo for NativeAtomicDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + max_bytes: u64, + ) -> IoFuture<'a, Result> { + let path = native_path(target).map(Path::to_path_buf); Box::pin(async move { - let path = native_path(target)?; - match fs::read(path) { - Ok(bytes) => Ok(Some(bytes)), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(error.into()), - } + let path = path?; + let max_bytes = usize::try_from(max_bytes).map_err(|_| AppError::Validation { + field: Some("documentRead".to_string()), + message: "document read limit is not supported on this platform".to_string(), + })?; + tokio::task::spawn_blocking(move || { + environment_engine::atomic_document::read_optional_bounded(&path, max_bytes) + .map(|bytes| DocumentSnapshot { + bytes, + generation: None, + }) + .map_err(AppError::from) + }) + .await + .map_err(native_document_task_error)? }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { - Box::pin(async move { write_native_atomic(native_path(target)?, &bytes) }) + ) -> IoFuture<'a, Result> { + Box::pin(async move { + let path = native_path(target).map_err(DocumentWriteFailure::not_published)?; + if expected.generation.is_some() { + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); + } + prepare_parent(path).map_err(DocumentWriteFailure::not_published)?; + environment_engine::atomic_document::replace_if_unchanged( + path, + expected.bytes.as_deref(), + &bytes, + ) + .map_err(DocumentWriteFailure::from_engine)?; + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let path = native_path(target).map_err(DocumentWriteFailure::not_published)?; + if expected.generation.is_some() { + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); + } + environment_engine::atomic_document::remove_if_unchanged( + path, + expected.bytes.as_deref(), + ) + .map_err(DocumentWriteFailure::from_engine) + }) } } pub(crate) fn write_native_atomic(path: &Path, bytes: &[u8]) -> Result<(), AppError> { - let parent = path.parent().ok_or_else(|| AppError::UnsafePath { - path: path.to_string_lossy().into_owned(), - reason: "document path has no parent".to_string(), - })?; - fs::create_dir_all(parent)?; - let legacy_backup = backup_path(path); - if legacy_backup.exists() { - fs::remove_file(&legacy_backup)?; - } + prepare_parent(path)?; + environment_engine::atomic_document::replace(path, bytes) + .map_err(DocumentWriteFailure::from_engine) + .map_err(DocumentWriteFailure::into_error) +} - let mut temporary = NamedTempFile::new_in(parent)?; - temporary.write_all(bytes)?; - temporary.as_file_mut().sync_all()?; - temporary.persist(path).map_err(|error| error.error)?; - sync_parent(parent)?; +fn prepare_parent(path: &Path) -> Result<(), AppError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| AppError::UnsafePath { + path: path.to_string_lossy().into_owned(), + reason: "document path has no explicit parent".to_string(), + })?; + // Parent creation remains the Native adapter's explicit private-store + // policy, not an implicit fallback inside the publication primitive. + fs::create_dir_all(parent)?; Ok(()) } +fn native_document_task_error(error: tokio::task::JoinError) -> AppError { + AppError::Io { + message: format!("native document task did not complete: {error}"), + } +} + +#[cfg(test)] pub(crate) fn backup_path(path: &Path) -> PathBuf { let mut name = path .file_name() @@ -72,17 +132,6 @@ fn native_path(locator: &ResourceLocator) -> Result<&Path, AppError> { Ok(Path::new(&locator.native_path)) } -#[cfg(unix)] -fn sync_parent(parent: &Path) -> Result<(), AppError> { - fs::File::open(parent)?.sync_all()?; - Ok(()) -} - -#[cfg(not(unix))] -fn sync_parent(_parent: &Path) -> Result<(), AppError> { - Ok(()) -} - #[cfg(test)] mod tests { use std::fs; @@ -101,42 +150,45 @@ mod tests { } #[tokio::test] - async fn atomic_write_round_trips_without_leaving_a_sidecar() { + async fn atomic_write_does_not_create_or_delete_an_unrelated_sidecar() { let temp = tempdir().expect("temp"); let path = temp.path().join("state/document.json"); let target = locator(&path); let io = NativeAtomicDocumentIo; - assert_eq!(io.read_optional(&target).await.unwrap(), None); + assert_eq!(io.observe(&target, 32).await.unwrap().bytes, None); assert!(!backup_path(&path).exists()); - io.write_atomic(&target, b"first".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"first".to_vec()) .await .expect("first write"); assert_eq!( - io.read_optional(&target).await.unwrap(), + io.observe(&target, 32).await.unwrap().bytes, Some(b"first".to_vec()) ); assert!(!backup_path(&path).exists()); fs::write(backup_path(&path), b"legacy backup").expect("legacy backup"); - io.write_atomic(&target, b"second".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"second".to_vec()) .await .expect("second write"); assert_eq!(fs::read(&path).unwrap(), b"second"); - assert!(!backup_path(&path).exists()); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"legacy backup"); - io.write_atomic(&target, b"third".to_vec()) + let snapshot = io.observe(&target, 32).await.unwrap(); + io.replace(&target, snapshot, b"third".to_vec()) .await .expect("third write"); assert_eq!(fs::read(&path).unwrap(), b"third"); - assert!(!backup_path(&path).exists()); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"legacy backup"); assert_eq!( fs::read_dir(path.parent().unwrap()) .unwrap() .filter_map(Result::ok) .count(), - 1 + 2 ); } @@ -150,8 +202,59 @@ mod tests { native_path: "/tmp/document.json".to_string(), }; assert!(matches!( - io.write_atomic(&target, b"data".to_vec()).await, + io.observe(&target, 32).await, Err(AppError::StorageUnsupported { .. }) )); } + #[tokio::test] + async fn conditional_save_uses_original_snapshot_not_a_fresh_revision() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + let io = NativeAtomicDocumentIo; + let missing = io.observe(&target, 16).await.unwrap(); + io.replace(&target, missing, b"old".to_vec()).await.unwrap(); + let snapshot = io.observe(&target, 16).await.unwrap(); + fs::write(&path, b"external").unwrap(); + let failure = io + .replace(&target, snapshot, b"new".to_vec()) + .await + .unwrap_err(); + assert_eq!( + failure.publication, + crate::storage::atomic_document::PublicationState::NotPublished + ); + assert!(matches!(failure.error, AppError::StaleTarget)); + assert_eq!(fs::read(path).unwrap(), b"external"); + } + + #[tokio::test] + async fn successful_replace_returns_the_committed_snapshot() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + fs::write(&path, b"old").unwrap(); + let snapshot = NativeAtomicDocumentIo.observe(&target, 16).await.unwrap(); + + let receipt = NativeAtomicDocumentIo + .replace(&target, snapshot, b"new".to_vec()) + .await + .unwrap(); + + assert_eq!(receipt.bytes.as_deref(), Some(b"new".as_slice())); + assert_eq!(receipt.generation, None); + } + + #[tokio::test] + async fn bounded_observe_rejects_an_oversized_document() { + let temp = tempdir().unwrap(); + let path = temp.path().join("document.json"); + let target = locator(&path); + fs::write(&path, b"12345").unwrap(); + + let result = NativeAtomicDocumentIo.observe(&target, 4).await; + + assert!(result.is_err()); + assert_eq!(fs::read(path).unwrap(), b"12345"); + } } diff --git a/src-tauri/src/environment/planning.rs b/src-tauri/src/environment/planning.rs index 57290803..d42f9947 100644 --- a/src-tauri/src/environment/planning.rs +++ b/src-tauri/src/environment/planning.rs @@ -238,7 +238,7 @@ impl TargetFactResolver for RuntimeTargetFactResolver { let cancellation_for_retry = cancellation.clone(); let workspace = self.environments.workspace(distro_name)?; self.environments - .with_session_retry(distro_name, move |session| { + .with_session_read_retry(distro_name, move |session| { let destinations = destinations.clone(); let cancellation = cancellation_for_retry.clone(); let workspace = workspace.clone(); diff --git a/src-tauri/src/environment/project_service.rs b/src-tauri/src/environment/project_service.rs index 67c2b1c0..c5e16dc9 100644 --- a/src-tauri/src/environment/project_service.rs +++ b/src-tauri/src/environment/project_service.rs @@ -317,7 +317,7 @@ pub async fn list_environment_projects( EnvironmentRef::Wsl { distro_name } => { let workspace = registry.workspace(&distro_name)?; registry - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let workspace = workspace.clone(); async move { let projects = read_wsl_projects(&session, &workspace).await?; diff --git a/src-tauri/src/environment/wsl.rs b/src-tauri/src/environment/wsl.rs index 2f61ffc5..eb7ddfd7 100644 --- a/src-tauri/src/environment/wsl.rs +++ b/src-tauri/src/environment/wsl.rs @@ -21,6 +21,34 @@ use crate::environment::types::{ }; use crate::error::AppError; +#[derive(Clone, Copy)] +struct RequestDeadline { + deadline: tokio::time::Instant, +} + +impl RequestDeadline { + fn from_start(started: tokio::time::Instant, limit: std::time::Duration) -> Self { + Self { + deadline: started + limit, + } + } + + fn new(limit: std::time::Duration) -> Self { + Self::from_start(tokio::time::Instant::now(), limit) + } + + fn remaining_at(self, now: tokio::time::Instant) -> Result { + self.deadline + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + .ok_or(AppError::WslCommandTimedOut) + } + + fn remaining(self) -> Result { + self.remaining_at(tokio::time::Instant::now()) + } +} + pub mod operations; pub(crate) mod protocol; mod worker; @@ -805,7 +833,7 @@ impl WslRuntime { } } - pub async fn with_session_retry( + pub async fn with_session_read_retry( &self, distro_name: &str, operation: O, @@ -1054,9 +1082,10 @@ impl WslWorkspace { cancellation: Option, limit: std::time::Duration, ) -> Result<(u64, environment_protocol::Message), AppError> { - let (worker, generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; let result = worker - .request_control_with_cancellation(message, limit, cancellation) + .request_control_with_cancellation(message, deadline.remaining()?, cancellation) .await; if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { self.registry.publish_unavailable_if_current( @@ -1075,10 +1104,11 @@ impl WslWorkspace { cancellation: Option, limit: std::time::Duration, ) -> Result { - let (worker, current_generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; self.require_worker_generation(generation, current_generation)?; let result = worker - .request_control_with_cancellation(message, limit, cancellation) + .request_control_with_cancellation(message, deadline.remaining()?, cancellation) .await; if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { self.registry.publish_unavailable_if_current( @@ -1101,10 +1131,16 @@ impl WslWorkspace { where T: serde::de::DeserializeOwned, { - let (worker, current_generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; self.require_worker_generation(generation, current_generation)?; let result = worker - .request_payload_with_limit(message, limit, max_payload_bytes, cancellation) + .request_payload_with_limit( + message, + deadline.remaining()?, + max_payload_bytes, + cancellation, + ) .await; if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { self.registry.publish_unavailable_if_current( @@ -1131,9 +1167,15 @@ impl WslWorkspace { where T: serde::de::DeserializeOwned, { - let (worker, generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; let result = worker - .request_payload_with_limit(message, limit, max_payload_bytes, cancellation) + .request_payload_with_limit( + message, + deadline.remaining()?, + max_payload_bytes, + cancellation, + ) .await; if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &result { self.registry.publish_unavailable_if_current( @@ -1158,10 +1200,11 @@ impl WslWorkspace { max_payload_bytes: usize, limit: std::time::Duration, ) -> Result, AppError> { - let (worker, current_generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; self.require_worker_generation(generation, current_generation)?; worker - .request_payload_with_limit(message, limit, max_payload_bytes, None) + .request_payload_with_limit(message, deadline.remaining()?, max_payload_bytes, None) .await } @@ -1173,10 +1216,16 @@ impl WslWorkspace { max_payload_bytes: usize, limit: std::time::Duration, ) -> Result { - let (worker, current_generation, _access) = self.worker_for_cycle().await?; + let deadline = RequestDeadline::new(limit); + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; self.require_worker_generation(generation, current_generation)?; worker - .send_prepared_transfer(transfer_id, payload, max_payload_bytes, limit) + .send_prepared_transfer( + transfer_id, + payload, + max_payload_bytes, + deadline.remaining()?, + ) .await } @@ -1187,6 +1236,16 @@ impl WslWorkspace { request: &environment_protocol::MutationUnitRequest, cancellation: crate::core::mutation::CancellationSignal, ) -> Result { + if request.deadline_millis == 0 + || request.deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + return Err(AppError::Validation { + field: Some("wslMutationDeadline".to_string()), + message: "invalid WSL mutation deadline".to_string(), + }); + } + let deadline = + RequestDeadline::new(std::time::Duration::from_millis(request.deadline_millis)); let payload = environment_protocol::encode_payload(request).map_err(|error| { AppError::ConfigurationCorrupted { message: format!("failed to encode WSL Worker mutation request: {error}"), @@ -1199,18 +1258,30 @@ impl WslWorkspace { }); } let digest = format!("sha256:{:x}", sha2::Sha256::digest(&payload)); - let prepared = self - .request_worker_control_for_generation( - generation, + let (worker, current_generation, _access) = self.worker_for_deadline(deadline).await?; + self.require_worker_generation(generation, current_generation)?; + let prepare_limit = deadline + .remaining()? + .min(std::time::Duration::from_secs(10)); + let prepared = worker + .request_control_with_cancellation( environment_protocol::Message::PrepareMutationUnit { resource_id: resource_id.to_string(), total_bytes: payload.len() as u64, sha256: digest, }, + prepare_limit, Some(cancellation.clone()), - std::time::Duration::from_secs(10), ) - .await?; + .await; + if let Err(error @ AppError::EnvironmentUnavailable { .. }) = &prepared { + self.registry.publish_unavailable_if_current( + &self.distro_name, + current_generation, + error.clone(), + ); + } + let prepared = prepared?; let transfer_id = match prepared { environment_protocol::Message::TransferReady { transfer_id } => transfer_id, environment_protocol::Message::Error { code, phase, .. } => { @@ -1226,20 +1297,20 @@ impl WslWorkspace { }); } }; - let (worker, current_generation, _access) = self.worker_for_cycle().await?; - self.require_worker_generation(generation, current_generation)?; + let mutation_limit = deadline.remaining()?; match worker .send_prepared_mutation( transfer_id, + resource_id, &payload, cancellation, - std::time::Duration::from_secs(125), + mutation_limit, ) .await { Ok(outcome) => Ok(outcome), Err(worker::MutationSessionError { - accepted_resource_id: Some(accepted), + recovery_resource_id: Some(accepted), error, }) => Err(AppError::RecoveryRequired { recovery_resource_id: crate::error::RecoveryResourceId::parse(accepted) @@ -1271,23 +1342,21 @@ impl WslWorkspace { message: environment_protocol::Message, cancellation: Option, ) -> Result, AppError> { + let deadline = RequestDeadline::new(std::time::Duration::from_secs(35)); for attempt in 0..=1 { - let (worker, generation, _access) = self.worker_for_cycle().await?; + let (worker, generation, _access) = self.worker_for_deadline(deadline).await?; + let remaining = deadline.remaining()?; let result = match &cancellation { Some(cancellation) => { worker .request_payload_with_cancellation( message.clone(), - std::time::Duration::from_secs(35), + remaining, cancellation.clone(), ) .await } - None => { - worker - .request_payload(message.clone(), std::time::Duration::from_secs(35)) - .await - } + None => worker.request_payload(message.clone(), remaining).await, }; match result { Ok(payload) => return Ok(payload), @@ -1304,6 +1373,15 @@ impl WslWorkspace { unreachable!("WSL Worker read retry has a fixed attempt count") } + async fn worker_for_deadline( + &self, + deadline: RequestDeadline, + ) -> Result<(worker::WorkerSession, u64, WslAccessPermit), AppError> { + tokio::time::timeout_at(deadline.deadline, self.worker_for_cycle()) + .await + .map_err(|_| AppError::WslCommandTimedOut)? + } + async fn worker_for_cycle( &self, ) -> Result<(worker::WorkerSession, u64, WslAccessPermit), AppError> { @@ -1822,11 +1900,28 @@ mod tests { use super::{ interpret_wsl_discovery_outcome, parse_wsl_list_output, parse_wsl_session_output, - WslDiscoveryCommandOutcome, WslRuntime, WslSession, + RequestDeadline, WslDiscoveryCommandOutcome, WslRuntime, WslSession, }; use crate::environment::types::{EnvironmentRef, EnvironmentRuntimeEvent, EnvironmentStatus}; use crate::error::{AppError, LockConflictTarget}; + #[test] + fn request_deadline_budget_is_shared_across_phases() { + let started = tokio::time::Instant::now(); + let deadline = RequestDeadline::from_start(started, std::time::Duration::from_secs(120)); + + assert_eq!( + deadline + .remaining_at(started + std::time::Duration::from_secs(10)) + .unwrap(), + std::time::Duration::from_secs(110) + ); + assert!(matches!( + deadline.remaining_at(started + std::time::Duration::from_secs(121)), + Err(AppError::WslCommandTimedOut) + )); + } + #[cfg(target_os = "linux")] fn command_output_with_timeout( command: &mut Command, diff --git a/src-tauri/src/environment/wsl/operations/atomic_file.rs b/src-tauri/src/environment/wsl/operations/atomic_file.rs index 1e1b47a8..0c8699c5 100644 --- a/src-tauri/src/environment/wsl/operations/atomic_file.rs +++ b/src-tauri/src/environment/wsl/operations/atomic_file.rs @@ -1,7 +1,10 @@ use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::environment::wsl::{WslSession, WslWorkspace}; use crate::error::AppError; -use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; +use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentSnapshot, DocumentWriteFailure, IoFuture, +}; +use sha2::{Digest, Sha256}; pub struct WslAtomicDocumentIo { access: WslAtomicDocumentAccess, @@ -55,38 +58,118 @@ impl WslAtomicDocumentIo { } impl AtomicDocumentIo for WslAtomicDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + max_bytes: u64, + ) -> IoFuture<'a, Result> { Box::pin(async move { - self.workspace() - .read_optional_document( - self.path(target)?.to_string(), - environment_protocol::MAX_DOCUMENT_BYTES, - ) - .await + let max_bytes = u32::try_from(max_bytes).map_err(|_| AppError::Validation { + field: Some("documentRead".to_string()), + message: "document read limit exceeds the WSL protocol".to_string(), + })?; + let snapshot = self + .workspace() + .read_optional_document_snapshot_once(self.path(target)?.to_string(), max_bytes) + .await?; + Ok(DocumentSnapshot { + bytes: snapshot.bytes, + generation: Some(snapshot.generation), + }) }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { Box::pin(async move { - let path = self.path(target)?.to_string(); - let snapshot = self - .workspace() - .read_optional_document_snapshot_once( - path.clone(), - environment_protocol::MAX_DOCUMENT_BYTES, - ) + let path = self + .path(target) + .map_err(DocumentWriteFailure::not_published)? + .to_string(); + let (generation, revision) = snapshot_binding(&expected)?; + // The request stays bound to the generation and original bytes. + // A transport error is not proof that the Worker did not publish. + self.workspace() + .commit_document_atomic(generation, path, revision, bytes.clone()) .await?; + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: Some(generation), + }) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let path = self + .path(target) + .map_err(DocumentWriteFailure::not_published)? + .to_string(); + let (generation, revision) = snapshot_binding(&expected)?; self.workspace() - .write_document_atomic(snapshot.generation, path, snapshot.revision, bytes) + .remove_document_if_revision(generation, path, revision) .await - .map(|_| ()) }) } } + +fn snapshot_binding( + snapshot: &DocumentSnapshot, +) -> Result<(u64, Option), DocumentWriteFailure> { + let generation = snapshot + .generation + .ok_or_else(|| DocumentWriteFailure::not_published(AppError::StaleEnvironment))?; + let revision = snapshot + .bytes + .as_deref() + .map(|bytes| format!("sha256:{:x}", Sha256::digest(bytes))); + Ok((generation, revision)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::atomic_document::{DocumentSnapshot, PublicationState}; + + #[test] + fn snapshot_binding_rejects_missing_worker_generation() { + let failure = snapshot_binding(&DocumentSnapshot { + bytes: None, + generation: None, + }) + .unwrap_err(); + assert_eq!(failure.publication, PublicationState::NotPublished); + assert!(matches!(failure.error, AppError::StaleEnvironment)); + } + + #[test] + fn snapshot_binding_distinguishes_missing_and_empty_documents() { + let absent = DocumentSnapshot { + bytes: None, + generation: Some(7), + }; + let empty = DocumentSnapshot { + bytes: Some(Vec::new()), + generation: Some(7), + }; + assert_eq!(snapshot_binding(&absent).unwrap(), (7, None)); + assert_eq!( + snapshot_binding(&empty).unwrap(), + ( + 7, + Some( + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string() + ) + ) + ); + } +} diff --git a/src-tauri/src/environment/wsl/operations/document.rs b/src-tauri/src/environment/wsl/operations/document.rs index 489ff549..2f104e03 100644 --- a/src-tauri/src/environment/wsl/operations/document.rs +++ b/src-tauri/src/environment/wsl/operations/document.rs @@ -1,5 +1,6 @@ use crate::environment::wsl::WslWorkspace; use crate::error::AppError; +use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState}; use sha2::{Digest, Sha256}; use tokio::time::Duration; @@ -108,16 +109,28 @@ impl WslWorkspace { expected_revision: Option, bytes: Vec, ) -> Result { - if !path.starts_with('/') - || bytes.is_empty() - || bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize + self.commit_document_atomic(generation, path, expected_revision, bytes) + .await + .map_err(DocumentWriteFailure::into_error) + } + + pub(crate) async fn commit_document_atomic( + &self, + generation: u64, + path: String, + expected_revision: Option, + bytes: Vec, + ) -> Result { + if !path.starts_with('/') || bytes.len() > environment_protocol::MAX_DOCUMENT_BYTES as usize { - return Err(AppError::Validation { + return Err(DocumentWriteFailure::not_published(AppError::Validation { field: Some("documentWrite".to_string()), message: "WSL document write requires an absolute path and bounded content" .to_string(), - }); + })); } + let deadline = + tokio::time::Instant::now() + Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS); let revision = document_revision(&bytes); let response = self .request_worker_control_for_generation( @@ -132,12 +145,17 @@ impl WslWorkspace { }, }, None, - Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + remaining_document_time(deadline)?, ) - .await?; + .await + .map_err(DocumentWriteFailure::not_published)?; let transfer_id = match response { environment_protocol::Message::TransferReady { transfer_id } => transfer_id, - message => return Err(document_write_response_error(message, "TransferReady")), + message => { + return Err(DocumentWriteFailure::not_published( + document_write_response_error(message, "TransferReady"), + )); + } }; let response = self .send_worker_transfer_for_generation( @@ -145,14 +163,15 @@ impl WslWorkspace { transfer_id, &bytes, environment_protocol::MAX_DOCUMENT_BYTES as usize, - Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), + remaining_document_time(deadline)?, ) - .await?; + .await + .map_err(DocumentWriteFailure::unknown)?; match response { environment_protocol::Message::DocumentWritten { revision: actual_revision, } if actual_revision == revision => Ok(revision), - message => Err(document_write_response_error(message, "DocumentWritten")), + message => Err(document_write_response_failure(message, "DocumentWritten")), } } @@ -161,12 +180,12 @@ impl WslWorkspace { generation: u64, path: String, expected_revision: Option, - ) -> Result<(), AppError> { + ) -> Result<(), DocumentWriteFailure> { if !path.starts_with('/') { - return Err(AppError::Validation { + return Err(DocumentWriteFailure::not_published(AppError::Validation { field: Some("documentRemove".to_string()), message: "WSL document remove requires an absolute path".to_string(), - }); + })); } let response = self .request_worker_control_for_generation( @@ -181,10 +200,11 @@ impl WslWorkspace { None, Duration::from_millis(DOCUMENT_WRITE_DEADLINE_MILLIS), ) - .await?; + .await + .map_err(DocumentWriteFailure::unknown)?; match response { environment_protocol::Message::DocumentRemoved => Ok(()), - message => Err(document_write_response_error(message, "DocumentRemoved")), + message => Err(document_write_response_failure(message, "DocumentRemoved")), } } } @@ -295,34 +315,108 @@ fn document_revision(bytes: &[u8]) -> String { format!("sha256:{:x}", Sha256::digest(bytes)) } +fn remaining_document_time( + deadline: tokio::time::Instant, +) -> Result { + deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| DocumentWriteFailure::not_published(AppError::WslCommandTimedOut)) +} + fn document_write_response_error( message: environment_protocol::Message, expected: &str, ) -> AppError { + document_write_response_failure(message, expected).into_error() +} + +fn document_write_response_failure( + message: environment_protocol::Message, + expected: &str, +) -> DocumentWriteFailure { match message { - environment_protocol::Message::Error { code, .. } if code == "documentConflict" => { - AppError::StaleTarget + environment_protocol::Message::Error { code, phase, .. } if code == "documentConflict" => { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::StaleTarget, + ) } - environment_protocol::Message::Error { code, .. } if code == "deadlineExceeded" => { - AppError::WslCommandTimedOut + environment_protocol::Message::Error { code, phase, .. } if code == "deadlineExceeded" => { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::WslCommandTimedOut, + ) } - environment_protocol::Message::Error { code, phase, .. } => AppError::ExecutionFailed { - message: format!("WSL Worker document write failed during {phase}: {code}"), - }, - _ => AppError::ConfigurationCorrupted { + environment_protocol::Message::Error { code, phase, .. } + if matches!( + code.as_str(), + "invalidRequest" | "invalidTarget" | "invalidTransfer" | "cancelled" + ) => + { + DocumentWriteFailure::not_published_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + environment_protocol::Message::Error { code, phase, .. } + if code == "documentPublishedUnconfirmed" => + { + DocumentWriteFailure { + error: AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + phase: document_write_phase(&phase), + publication: PublicationState::PublishedUnconfirmed, + } + } + environment_protocol::Message::Error { code, phase, .. } + if code == "documentOutcomeUnknown" => + { + DocumentWriteFailure::unknown_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + environment_protocol::Message::Error { code, phase, .. } => { + DocumentWriteFailure::unknown_at( + document_write_phase(&phase), + AppError::ExecutionFailed { + message: format!("WSL Worker document write failed during {phase}: {code}"), + }, + ) + } + _ => DocumentWriteFailure::unknown(AppError::ConfigurationCorrupted { message: format!("WSL Worker returned an invalid {expected} response"), - }, + }), + } +} + +fn document_write_phase(phase: &str) -> crate::storage::atomic_document::WritePhase { + use crate::storage::atomic_document::WritePhase; + + match phase { + "writing" => WritePhase::Writing, + "beforePublish" => WritePhase::BeforePublish, + "publishing" => WritePhase::Publishing, + "confirming" => WritePhase::Confirming, + _ => WritePhase::Preparing, } } #[cfg(test)] mod tests { - use super::document_write_response_error; + use super::document_write_response_failure; use crate::error::AppError; + use crate::storage::atomic_document::{PublicationState, WritePhase}; #[test] fn document_conflict_maps_to_stale_target() { - let error = document_write_response_error( + let failure = document_write_response_failure( environment_protocol::Message::Error { code: "documentConflict".to_string(), phase: "documentWrite".to_string(), @@ -331,6 +425,30 @@ mod tests { "DocumentWritten", ); - assert_eq!(error, AppError::StaleTarget); + assert_eq!(failure.error, AppError::StaleTarget); + assert_eq!(failure.publication, PublicationState::NotPublished); + } + + #[test] + fn worker_publication_codes_retain_their_commit_state() { + for (code, publication) in [ + ( + "documentPublishedUnconfirmed", + PublicationState::PublishedUnconfirmed, + ), + ("documentOutcomeUnknown", PublicationState::OutcomeUnknown), + ] { + let failure = document_write_response_failure( + environment_protocol::Message::Error { + code: code.to_string(), + phase: "confirming".to_string(), + parameters: Vec::new(), + }, + "DocumentWritten", + ); + + assert_eq!(failure.publication, publication); + assert_eq!(failure.phase, WritePhase::Confirming); + } } } diff --git a/src-tauri/src/runtime/planning_facts.rs b/src-tauri/src/runtime/planning_facts.rs index 86cbd232..8d4e0fb2 100644 --- a/src-tauri/src/runtime/planning_facts.rs +++ b/src-tauri/src/runtime/planning_facts.rs @@ -105,7 +105,7 @@ impl RuntimePlanningFactSource { let registry = Arc::clone(®istry); let workspace = self.environments.workspace(distro_name)?; self.environments - .with_session_retry(distro_name, move |session| { + .with_session_read_retry(distro_name, move |session| { let context = context.clone(); let registry = Arc::clone(®istry); let workspace = workspace.clone(); @@ -482,7 +482,11 @@ where I: AtomicDocumentIo + ?Sized, { let current = ProjectsFile::new(Vec::new(), semantics); - let Some(bytes) = io.read_optional(target).await? else { + let Some(bytes) = io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + else { return Ok(current); }; let parsed: ProjectsFile = serde_json::from_slice(&bytes)?; @@ -573,7 +577,9 @@ mod tests { use crate::core::agent_registry::AgentRegistrySnapshot; use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; use crate::environment::wsl::{WslRuntime, WslSession}; - use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; + use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentCommitReceipt, DocumentSnapshot, DocumentWriteFailure, IoFuture, + }; struct StaticRegistry(Arc); @@ -589,21 +595,34 @@ mod tests { } impl AtomicDocumentIo for RecordingDocumentIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a crate::environment::types::ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { + _max_bytes: u64, + ) -> IoFuture<'a, Result> { Box::pin(async move { self.reads.lock().unwrap().push(target.native_path.clone()); - Ok(Some(self.bytes.clone())) + Ok(DocumentSnapshot { + bytes: Some(self.bytes.clone()), + generation: None, + }) }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, _target: &'a crate::environment::types::ResourceLocator, + _expected: DocumentSnapshot, _bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { + Box::pin(async { panic!("context capture is read-only") }) + } + + fn remove<'a>( + &'a self, + _target: &'a crate::environment::types::ResourceLocator, + _expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { Box::pin(async { panic!("context capture is read-only") }) } } diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index 5ffbf9f9..efeeef31 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -166,7 +166,7 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { let distro_name = distro_name.clone(); let library_id = library_id.as_str().to_string(); self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); async move { Ok(( @@ -363,7 +363,7 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { let skill_name = install_dir_name; let path = self .wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); let skill_name = skill_name.clone(); async move { @@ -479,7 +479,7 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { let context = context.clone(); let workspace = self.wsl.workspace(&distro_name)?; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let context = context.clone(); let workspace = workspace.clone(); async move { @@ -576,7 +576,7 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { let library_id = library_id.as_str().to_string(); let skill_name = install_dir_name; self.wsl - .with_session_retry(&distro_name, move |session| { + .with_session_read_retry(&distro_name, move |session| { let library_id = library_id.clone(); let skill_name = skill_name.clone(); async move { @@ -719,6 +719,20 @@ fn save_native_catalog( crate::environment::native::atomic_file::write_native_atomic(&root.join("catalog.json"), bytes) } +fn save_native_catalog_if_unchanged( + root: &Path, + expected: Option<&[u8]>, + bytes: &[u8], +) -> Result<(), AppError> { + environment_engine::atomic_document::replace_if_unchanged( + &root.join("catalog.json"), + expected, + bytes, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error) +} + fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result { let original_bytes = match fs::read(root.join("catalog.json")) { Ok(bytes) => bytes, @@ -1128,10 +1142,7 @@ fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Res } } prepare_native_catalog_commit(root, &catalog_hash)?; - crate::environment::native::atomic_file::write_native_atomic( - &root.join("catalog.json"), - &catalog_bytes, - )?; + save_native_catalog_if_unchanged(root, current_catalog_bytes.as_deref(), &catalog_bytes)?; finalize_native_catalog_commit(root, &catalog_hash) })(); if let Err(error) = commit { diff --git a/src-tauri/src/runtime/wsl_source.rs b/src-tauri/src/runtime/wsl_source.rs index 3fb1bc34..ec84400c 100644 --- a/src-tauri/src/runtime/wsl_source.rs +++ b/src-tauri/src/runtime/wsl_source.rs @@ -242,7 +242,7 @@ impl RuntimeWslSourceAccess { let proxy_distro = distro_name.to_string(); let git_timeout = Duration::from_secs(resolve_clone_timeout_secs()); self.environments - .with_session_retry(distro_name, move |session| { + .with_session(distro_name, move |session| { let workspace = workspace.clone(); let parsed = parsed.clone(); let requested_source = requested_source.clone(); diff --git a/src-tauri/src/storage/atomic_document.rs b/src-tauri/src/storage/atomic_document.rs index 7b980d09..dad0455f 100644 --- a/src-tauri/src/storage/atomic_document.rs +++ b/src-tauri/src/storage/atomic_document.rs @@ -1,20 +1,96 @@ use std::future::Future; use std::pin::Pin; +pub use environment_engine::atomic_document::{PublicationState, WritePhase}; + use crate::environment::types::ResourceLocator; use crate::error::AppError; pub type IoFuture<'a, T> = Pin + Send + 'a>>; +/// Full bytes and the runtime session in which they were observed. A generation +/// is never a persisted document version and must not be written into JSON. +#[derive(Debug, Clone)] +pub struct DocumentSnapshot { + pub bytes: Option>, + pub generation: Option, +} + +pub type DocumentCommitReceipt = DocumentSnapshot; + +#[derive(Debug)] +pub struct DocumentWriteFailure { + pub error: AppError, + pub phase: WritePhase, + pub publication: PublicationState, +} + +impl DocumentWriteFailure { + pub fn not_published(error: AppError) -> Self { + Self::not_published_at(WritePhase::Preparing, error) + } + + pub fn not_published_at(phase: WritePhase, error: AppError) -> Self { + Self { + error, + phase, + publication: PublicationState::NotPublished, + } + } + + pub fn unknown(error: AppError) -> Self { + Self::unknown_at(WritePhase::Publishing, error) + } + + pub fn unknown_at(phase: WritePhase, error: AppError) -> Self { + Self { + error, + phase, + publication: PublicationState::OutcomeUnknown, + } + } + + pub fn into_error(self) -> AppError { + self.error + } + + pub(crate) fn from_engine( + error: environment_engine::atomic_document::AtomicWriteError, + ) -> Self { + let publication = error.publication; + let phase = error.phase; + let error = if error.is_conflict() { + AppError::StaleTarget + } else { + AppError::Io { + message: error.to_string(), + } + }; + Self { + error, + phase, + publication, + } + } +} + pub trait AtomicDocumentIo: Send + Sync { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>>; + max_bytes: u64, + ) -> IoFuture<'a, Result>; - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>>; + ) -> IoFuture<'a, Result>; + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>>; } diff --git a/src-tauri/src/storage/lock_plan.rs b/src-tauri/src/storage/lock_plan.rs index 6f9f95a7..9a0e62e9 100644 --- a/src-tauri/src/storage/lock_plan.rs +++ b/src-tauri/src/storage/lock_plan.rs @@ -9,7 +9,7 @@ use crate::core::lossless_lock::{ }; use crate::environment::types::ResourceLocator; use crate::error::AppError; -use crate::storage::atomic_document::AtomicDocumentIo; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure}; #[derive(Debug, Clone)] pub struct LockCommitReceipt { @@ -134,20 +134,34 @@ where pub async fn commit( &self, prepared: PreparedLockMutation, - ) -> Result { - let current = self.io.read_optional(&prepared.target).await?; - let legacy = match (¤t, &prepared.legacy_target) { - (None, Some(target)) => self.io.read_optional(target).await?, + ) -> Result { + let current = self + .io + .observe( + &prepared.target, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), + ) + .await + .map_err(DocumentWriteFailure::not_published)?; + let legacy = match (¤t.bytes, &prepared.legacy_target) { + (None, Some(target)) => { + self.io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await + .map_err(DocumentWriteFailure::not_published)? + .bytes + } _ => None, }; let applied = environment_engine::lock::apply( - current.as_deref(), + current.bytes.as_deref(), legacy.as_deref(), &engine_mutation(&prepared), ) - .map_err(map_engine_error)?; + .map_err(map_engine_error) + .map_err(DocumentWriteFailure::not_published)?; self.io - .write_atomic(&prepared.target, applied.bytes) + .replace(&prepared.target, current, applied.bytes) .await?; Ok(LockCommitReceipt { entry_snapshots: applied @@ -244,14 +258,22 @@ pub async fn load_lock_document( where I: AtomicDocumentIo + ?Sized, { - if let Some(bytes) = io.read_optional(target).await? { + if let Some(bytes) = io + .observe(target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + { ensure_supported_schema(&bytes, schema)?; return LosslessLockDocument::parse(&bytes); } let Some(legacy) = legacy_target else { return Ok(LosslessLockDocument::empty(schema)); }; - let Some(bytes) = io.read_optional(legacy).await? else { + let Some(bytes) = io + .observe(legacy, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await? + .bytes + else { return Ok(LosslessLockDocument::empty(schema)); }; let document = LosslessLockDocument::parse(&bytes)?; @@ -290,7 +312,9 @@ mod tests { use super::*; use crate::environment::types::EnvironmentRef; - use crate::storage::atomic_document::{AtomicDocumentIo, IoFuture}; + use crate::storage::atomic_document::{ + AtomicDocumentIo, DocumentCommitReceipt, DocumentSnapshot, DocumentWriteFailure, IoFuture, + }; #[derive(Default)] struct FakeIo { @@ -298,25 +322,49 @@ mod tests { } impl AtomicDocumentIo for FakeIo { - fn read_optional<'a>( + fn observe<'a>( &'a self, target: &'a ResourceLocator, - ) -> IoFuture<'a, Result>, AppError>> { - Box::pin( - async move { Ok(self.files.lock().unwrap().get(&target.native_path).cloned()) }, - ) + _max_bytes: u64, + ) -> IoFuture<'a, Result> { + Box::pin(async move { + Ok(DocumentSnapshot { + bytes: self.files.lock().unwrap().get(&target.native_path).cloned(), + generation: None, + }) + }) } - fn write_atomic<'a>( + fn replace<'a>( &'a self, target: &'a ResourceLocator, + expected: DocumentSnapshot, bytes: Vec, - ) -> IoFuture<'a, Result<(), AppError>> { + ) -> IoFuture<'a, Result> { Box::pin(async move { - self.files - .lock() - .unwrap() - .insert(target.native_path.clone(), bytes); + let mut files = self.files.lock().unwrap(); + if files.get(&target.native_path) != expected.bytes.as_ref() { + return Err(DocumentWriteFailure::not_published(AppError::StaleTarget)); + } + files.insert(target.native_path.clone(), bytes.clone()); + Ok(DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: DocumentSnapshot, + ) -> IoFuture<'a, Result<(), DocumentWriteFailure>> { + Box::pin(async move { + let mut files = self.files.lock().unwrap(); + if files.get(&target.native_path) != expected.bytes.as_ref() { + return Err(DocumentWriteFailure::not_published(AppError::StaleTarget)); + } + files.remove(&target.native_path); Ok(()) }) } @@ -403,7 +451,11 @@ mod tests { ); assert!(matches!( committer.commit(mutation(expected)).await, - Err(AppError::LockConflict { .. }) + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) )); } @@ -432,7 +484,11 @@ mod tests { assert!(matches!( committer.commit(prepared).await, - Err(AppError::LockConflict { .. }) + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) )); } @@ -532,7 +588,14 @@ mod tests { }) .await; - assert!(matches!(result, Err(AppError::LockConflict { .. }))); + assert!(matches!( + result, + Err(DocumentWriteFailure { + error: AppError::LockConflict { .. }, + publication: crate::storage::atomic_document::PublicationState::NotPublished, + .. + }) + )); assert_eq!( io.files.lock().unwrap()[&locator().native_path], external_bytes From 6996809fefff66860367c09f7023c172ea3c9935 Mon Sep 17 00:00:00 2001 From: Hccake Date: Sun, 6 Sep 2026 23:18:43 +0800 Subject: [PATCH 08/27] refactor(mutation): enforce transaction and recovery guarantees Keep production mutations inside complete execution units. Retain recovery evidence when publication or restoration is uncertain, and preserve committed outcomes when only cleanup remains. Probe destinations before Scope writes, bound WSL transfer and cancellation lifetimes through terminal results, and preserve Library directory transaction and deletion guarantees. --- .../crates/environment-engine/src/library.rs | 16 +- .../environment-engine/src/linux_mutation.rs | 66 ++ .../environment-engine/tests/library.rs | 67 ++ .../tests/linux_mutation.rs | 21 +- .../crates/environment-protocol/src/lib.rs | 18 + .../crates/wsl-environment-worker/src/lib.rs | 51 +- .../crates/wsl-environment-worker/src/main.rs | 191 ++++- .../wsl-environment-worker/tests/library.rs | 56 ++ .../wsl-environment-worker/tests/runtime.rs | 28 +- .../wsl-environment-worker/tests/stdio.rs | 139 +++- .../src/application/mutation/coordinator.rs | 59 +- src-tauri/src/environment/native/entry.rs | 46 ++ .../src/environment/native/materialize.rs | 687 +++++++++++++++++- src-tauri/src/environment/native/recovery.rs | 69 +- .../environment/wsl/operations/materialize.rs | 49 +- src-tauri/src/environment/wsl/worker.rs | 428 ++++++++--- src-tauri/src/runtime/plan_runner.rs | 40 +- src-tauri/src/runtime/skill_libraries.rs | 271 ++++++- src-tauri/src/test_support/native_workflow.rs | 21 +- 19 files changed, 2103 insertions(+), 220 deletions(-) diff --git a/src-tauri/crates/environment-engine/src/library.rs b/src-tauri/crates/environment-engine/src/library.rs index 1853d360..f7e3ed96 100644 --- a/src-tauri/crates/environment-engine/src/library.rs +++ b/src-tauri/crates/environment-engine/src/library.rs @@ -13,6 +13,7 @@ pub struct CatalogSnapshot { pub struct CatalogWrite { pub expected_revision: Option, pub bytes: Vec, + pub max_current_bytes: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -182,6 +183,7 @@ fn commit_platform(request: LibraryCommit) -> Result<(), LibraryError> { &request.root.join("catalog.json"), request.catalog.expected_revision.as_deref(), &request.catalog.bytes, + request.catalog.max_current_bytes, )?; write_state(&transaction.join("phase"), "catalogCommitted")?; remove_any(&backup)?; @@ -254,6 +256,7 @@ fn write_catalog_platform( &root.join("catalog.json"), catalog.expected_revision.as_deref(), &catalog.bytes, + catalog.max_current_bytes, ); if result.is_err() { for library in created { @@ -336,8 +339,9 @@ fn write_catalog_document( path: &Path, expected_revision: Option<&str>, bytes: &[u8], + max_current_bytes: usize, ) -> Result<(), LibraryError> { - crate::document::write_document_atomic(path, expected_revision, bytes) + crate::document::write_document_atomic(path, expected_revision, bytes, max_current_bytes) .map(|_| ()) .map_err(|error| match error { crate::document::DocumentWriteError::Conflict => LibraryError::StaleTarget, @@ -345,7 +349,7 @@ fn write_catalog_document( crate::document::DocumentWriteError::UnsupportedPlatform => { LibraryError::UnsupportedPlatform } - crate::document::DocumentWriteError::Io => { + crate::document::DocumentWriteError::Io { .. } => { LibraryError::Io(std::io::Error::other("failed to write Library catalog")) } }) @@ -431,6 +435,13 @@ fn recover(root: &Path, catalog_revision: Option<&str>) -> Result<(), LibraryErr }; let stage = transaction.join("stage"); let backup = transaction.join("backup"); + if phase == "catalogCommitted" { + // Keep the commit evidence until all retained content is removed. + if remove_any(&stage).is_ok() && remove_any(&backup).is_ok() { + let _ = std::fs::remove_dir_all(&transaction); + } + continue; + } match phase.as_str() { "preparing" | "staged" => {} "backedUp" if !destination.exists() && backup.is_dir() && stage.exists() => { @@ -445,7 +456,6 @@ fn recover(root: &Path, catalog_revision: Option<&str>) -> Result<(), LibraryErr rollback(&destination, &backup)?; } } - "catalogCommitted" if destination.exists() == desired_presence => {} _ => return Err(LibraryError::RecoveryIncomplete), } remove_any(&stage)?; diff --git a/src-tauri/crates/environment-engine/src/linux_mutation.rs b/src-tauri/crates/environment-engine/src/linux_mutation.rs index 4ba10fed..01454db8 100644 --- a/src-tauri/crates/environment-engine/src/linux_mutation.rs +++ b/src-tauri/crates/environment-engine/src/linux_mutation.rs @@ -3,6 +3,8 @@ use std::fmt; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::sync::atomic::{AtomicU64, Ordering}; use sha2::{Digest, Sha256}; use unicode_normalization::UnicodeNormalization; @@ -128,6 +130,70 @@ pub fn fingerprint_path(path: &Path) -> Result { Ok(format!("entry-v1-{:x}", hasher.finalize())) } +pub fn preflight_write_targets( + destinations: &[PathBuf], + is_cancelled: F, +) -> Result<(), MutationError> +where + F: Fn() -> bool, +{ + preflight_write_targets_platform(destinations, is_cancelled) +} + +#[cfg(not(target_os = "linux"))] +fn preflight_write_targets_platform( + _destinations: &[PathBuf], + _is_cancelled: impl Fn() -> bool, +) -> Result<(), MutationError> { + Err(MutationError::UnsupportedPlatform) +} + +#[cfg(target_os = "linux")] +fn preflight_write_targets_platform( + destinations: &[PathBuf], + is_cancelled: impl Fn() -> bool, +) -> Result<(), MutationError> { + static NEXT_PROBE: AtomicU64 = AtomicU64::new(1); + + if destinations.is_empty() || destinations.iter().any(|path| !path.is_absolute()) { + return Err(MutationError::InvalidRequest); + } + for destination in destinations { + if is_cancelled() { + return Err(MutationError::Cancelled); + } + let mut parent = destination.parent().ok_or(MutationError::InvalidRequest)?; + loop { + match fs::symlink_metadata(parent) { + Ok(_) => break, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + parent = parent.parent().ok_or(MutationError::InvalidRequest)?; + } + Err(error) => return Err(error.into()), + } + } + if !fs::metadata(parent).is_ok_and(|metadata| metadata.is_dir()) { + return Err(MutationError::InvalidRequest); + } + let sequence = NEXT_PROBE.fetch_add(1, Ordering::Relaxed); + let probe = parent.join(format!( + ".skill-deck-preflight-{}-{sequence}", + std::process::id() + )); + let renamed = probe.with_extension("renamed"); + fs::create_dir(&probe)?; + if let Err(error) = fs::rename(&probe, &renamed) { + let _ = fs::remove_dir(&probe); + return Err(error.into()); + } + if let Err(error) = fs::remove_dir(&renamed) { + let _ = fs::remove_dir(&renamed); + return Err(error.into()); + } + } + Ok(()) +} + pub fn validate_intents(intents: &[EntryIntent], is_cancelled: F) -> Result<(), MutationError> where F: Fn() -> bool, diff --git a/src-tauri/crates/environment-engine/tests/library.rs b/src-tauri/crates/environment-engine/tests/library.rs index 396f5adb..cb5a4a79 100644 --- a/src-tauri/crates/environment-engine/tests/library.rs +++ b/src-tauri/crates/environment-engine/tests/library.rs @@ -1,5 +1,6 @@ #![cfg(target_os = "linux")] +use std::os::unix::ffi::OsStrExt; use std::path::Path; use environment_engine::library::{ @@ -29,6 +30,7 @@ fn member_upsert_commits_content_and_catalog_as_one_intent() { catalog: CatalogWrite { expected_revision: None, bytes: catalog.clone(), + max_current_bytes: 1024, }, }) .unwrap(); @@ -64,6 +66,7 @@ fn member_commit_accepts_a_managed_root_reached_through_a_symlinked_parent() { catalog: CatalogWrite { expected_revision: None, bytes: br#"{"schemaVersion":1}"#.to_vec(), + max_current_bytes: 1024, }, }) .unwrap(); @@ -74,6 +77,31 @@ fn member_commit_accepts_a_managed_root_reached_through_a_symlinked_parent() { ); } +#[test] +fn committed_cleanup_ignores_a_later_destination_state() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-one/skills/demo"); + std::fs::create_dir_all(transaction.join("backup")).unwrap(); + std::fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"1").unwrap(); + std::fs::write(transaction.join("expected-catalog-hash"), b"committed").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogCommitted").unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("catalog.json"), br#"{"schemaVersion":1}"#).unwrap(); + + let loaded = read_catalog(&root); + + assert!(loaded.is_ok()); + assert!(!transaction.exists()); +} + #[test] fn catalog_write_is_conditional_and_creates_declared_library_roots() { let temp = tempfile::tempdir().unwrap(); @@ -85,6 +113,7 @@ fn catalog_write_is_conditional_and_creates_declared_library_roots() { CatalogWrite { expected_revision: None, bytes: first, + max_current_bytes: 1024, }, ) .unwrap(); @@ -96,6 +125,7 @@ fn catalog_write_is_conditional_and_creates_declared_library_roots() { CatalogWrite { expected_revision: Some(revision), bytes: second.clone(), + max_current_bytes: 1024, }, ) .unwrap(); @@ -109,6 +139,7 @@ fn catalog_write_is_conditional_and_creates_declared_library_roots() { CatalogWrite { expected_revision: Some("sha256:wrong".to_string()), bytes: b"stale".to_vec(), + max_current_bytes: 1024, }, ), Err(LibraryError::StaleTarget) @@ -116,6 +147,40 @@ fn catalog_write_is_conditional_and_creates_declared_library_roots() { assert_eq!(read_catalog(&root).unwrap().bytes, Some(second)); } +#[test] +fn committed_cleanup_failure_remains_retryable_across_catalog_reads() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("skill-libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-1"); + std::fs::create_dir_all(&transaction).unwrap(); + std::fs::write( + transaction.join("destination"), + destination.as_os_str().as_bytes(), + ) + .unwrap(); + std::fs::write(transaction.join("desired-presence"), b"0").unwrap(); + std::fs::write(transaction.join("expected-catalog-hash"), b"committed").unwrap(); + std::fs::write(transaction.join("phase"), b"catalogCommitted").unwrap(); + let catalog = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + std::fs::write(root.join("catalog.json"), &catalog).unwrap(); + let locked = transaction.join("backup/locked"); + std::fs::create_dir_all(&locked).unwrap(); + std::fs::write(locked.join("SKILL.md"), b"remaining backup").unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let first = read_catalog(&root); + let second = read_catalog(&root); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!(first.unwrap().bytes, Some(catalog.clone())); + assert_eq!(second.unwrap().bytes, Some(catalog.clone())); + assert_eq!(read_catalog(&root).unwrap().bytes, Some(catalog)); + assert!(!transaction.exists()); +} + #[test] fn delete_commit_removes_the_destination_and_updates_catalog() { let temp = tempfile::tempdir().unwrap(); @@ -130,6 +195,7 @@ fn delete_commit_removes_the_destination_and_updates_catalog() { CatalogWrite { expected_revision: None, bytes: first, + max_current_bytes: 1024, }, ) .unwrap(); @@ -144,6 +210,7 @@ fn delete_commit_removes_the_destination_and_updates_catalog() { catalog: CatalogWrite { expected_revision: Some(revision), bytes: second.clone(), + max_current_bytes: 1024, }, }) .unwrap(); diff --git a/src-tauri/crates/environment-engine/tests/linux_mutation.rs b/src-tauri/crates/environment-engine/tests/linux_mutation.rs index e4029bcf..8ca9735d 100644 --- a/src-tauri/crates/environment-engine/tests/linux_mutation.rs +++ b/src-tauri/crates/environment-engine/tests/linux_mutation.rs @@ -1,9 +1,26 @@ #![cfg(target_os = "linux")] use environment_engine::linux_mutation::{ - content_hash_path, fingerprint_path, parent_identity, EntryAction, EntryIntent, MutationError, - StagedMutation, + content_hash_path, fingerprint_path, parent_identity, preflight_write_targets, EntryAction, + EntryIntent, MutationError, StagedMutation, }; + +#[test] +fn write_preflight_rejects_an_already_read_only_parent_without_leaving_files() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("read-only"); + let destination = parent.join("demo"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = preflight_write_targets(std::slice::from_ref(&destination), || false); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + assert_eq!(std::fs::read_dir(parent).unwrap().count(), 0); +} use environment_engine::payload::build_payload; #[test] diff --git a/src-tauri/crates/environment-protocol/src/lib.rs b/src-tauri/crates/environment-protocol/src/lib.rs index 38ae69b9..5165181c 100644 --- a/src-tauri/crates/environment-protocol/src/lib.rs +++ b/src-tauri/crates/environment-protocol/src/lib.rs @@ -366,6 +366,17 @@ pub struct ProjectionResponse { pub targets: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteProbeRequest { + pub destinations: Vec, + pub deadline_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteProbeResponse { + pub checked_count: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestRequest { pub root: String, @@ -733,6 +744,9 @@ pub enum Message { ProjectTargets { request: ProjectionRequest, }, + ProbeWriteTargets { + request: WriteProbeRequest, + }, BuildManifest { request: ManifestRequest, }, @@ -957,6 +971,10 @@ impl ProtocolWriter { .map_err(|_| WriterError::Closed) } + pub fn try_send_control(&self, record: WireRecord) -> bool { + self.control.try_send(record).is_ok() + } + pub async fn send_binary(&self, record: WireRecord) -> Result<(), WriterError> { self.binary .send(BinaryRecord::Record(record)) diff --git a/src-tauri/crates/wsl-environment-worker/src/lib.rs b/src-tauri/crates/wsl-environment-worker/src/lib.rs index 6a0af937..6620b43e 100644 --- a/src-tauri/crates/wsl-environment-worker/src/lib.rs +++ b/src-tauri/crates/wsl-environment-worker/src/lib.rs @@ -10,7 +10,8 @@ use environment_engine::inspection::{ #[cfg(target_os = "linux")] use environment_engine::{ directory as engine_directory, document as engine_document, entry as engine_entry, - manifest as engine_manifest, path as engine_path, projection as engine_projection, + linux_mutation as engine_linux_mutation, manifest as engine_manifest, path as engine_path, + projection as engine_projection, }; #[cfg(target_os = "linux")] use environment_protocol::{ @@ -26,7 +27,8 @@ use environment_protocol::{ DocumentReadRequest, DocumentReadResponse, EntryFactsRequest, EntryFactsResponse, InspectionRequest, InspectionResponse, ManifestRequest, ManifestResponse, MapWindowsPathsRequest, MapWindowsPathsResponse, Message, PathKind, PathMetadataRequest, - PathMetadataResponse, ProjectionRequest, ProjectionResponse, + PathMetadataResponse, ProjectionRequest, ProjectionResponse, WriteProbeRequest, + WriteProbeResponse, }; use sha2::{Digest, Sha256}; @@ -582,6 +584,40 @@ where Ok(ProjectionResponse { targets }) } +#[cfg(target_os = "linux")] +pub fn execute_write_probe( + request: WriteProbeRequest, + is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + validate_paths( + &request.destinations, + request.deadline_millis, + "writePreflight", + )?; + let checked_count = u32::try_from(request.destinations.len()) + .map_err(|_| planning_error("invalidRequest", "writePreflight"))?; + engine_linux_mutation::preflight_write_targets( + &request + .destinations + .into_iter() + .map(Into::into) + .collect::>(), + is_cancelled, + ) + .map_err(|error| { + let code = match error { + engine_linux_mutation::MutationError::Cancelled => "cancelled", + engine_linux_mutation::MutationError::InvalidRequest => "invalidRequest", + _ => "writeProbeFailed", + }; + planning_error(code, "writePreflight") + })?; + Ok(WriteProbeResponse { checked_count }) +} + #[cfg(target_os = "linux")] pub fn execute_manifest( request: ManifestRequest, @@ -728,6 +764,17 @@ where Err(planning_error("unsupportedPlatform", "projection")) } +#[cfg(not(target_os = "linux"))] +pub fn execute_write_probe( + _request: WriteProbeRequest, + _is_cancelled: F, +) -> Result +where + F: Fn() -> bool, +{ + Err(planning_error("unsupportedPlatform", "writePreflight")) +} + #[cfg(not(target_os = "linux"))] pub fn execute_manifest( _request: ManifestRequest, diff --git a/src-tauri/crates/wsl-environment-worker/src/main.rs b/src-tauri/crates/wsl-environment-worker/src/main.rs index 5cc011e9..ffc5b3c7 100644 --- a/src-tauri/crates/wsl-environment-worker/src/main.rs +++ b/src-tauri/crates/wsl-environment-worker/src/main.rs @@ -30,8 +30,8 @@ use wsl_environment_worker::source::{ use wsl_environment_worker::{ error_message, execute_directory_count, execute_directory_list, execute_document_read, execute_entry_facts, execute_inspection, execute_manifest, execute_map_windows_paths, - execute_path_metadata, execute_path_observation, execute_projection, file_sha256, RequestError, - WorkerIdentity, WorkerRuntime, + execute_path_metadata, execute_path_observation, execute_projection, execute_write_probe, + file_sha256, RequestError, WorkerIdentity, WorkerRuntime, }; struct QueuedRequest { @@ -62,6 +62,8 @@ enum InboundAction { } struct PreparedInbound { + owner_request_id: u64, + deadline: Instant, transfer_id: u64, total_bytes: u64, sha256: String, @@ -73,6 +75,7 @@ struct PreparedInbound { struct ActiveInbound { owner_request_id: u64, + deadline: Instant, path: PathBuf, action: InboundAction, transfer: InboundTransfer, @@ -142,7 +145,37 @@ async fn main() -> Result<(), Box> { break; } + let inbound_deadline = prepared_inbound + .as_ref() + .map(|inbound| inbound.deadline) + .or_else(|| active_inbound.as_ref().map(|inbound| inbound.deadline)); + let inbound_deadline_sleep = + tokio::time::sleep_until(inbound_deadline.unwrap_or_else(Instant::now).into()); + tokio::pin!(inbound_deadline_sleep); + tokio::select! { + _ = &mut inbound_deadline_sleep, if inbound_deadline.is_some() => { + if let Some(inbound) = prepared_inbound.take() { + drop(inbound.file); + finish_inbound_with_error( + &writer, + inbound.owner_request_id, + "deadlineExceeded", + &payloads, + inbound.path, + inbound.action, + ).await?; + } else if let Some(inbound) = active_inbound.take() { + finish_inbound_with_error( + &writer, + inbound.owner_request_id, + "deadlineExceeded", + &payloads, + inbound.path, + inbound.action, + ).await?; + } + } completed = tasks.join_next(), if !tasks.is_empty() => { match completed { Some(Ok(Ok(request_id))) => { @@ -183,6 +216,7 @@ async fn main() -> Result<(), Box> { | Message::MapHostPaths { .. } | Message::InspectEntries { .. } | Message::ProjectTargets { .. } + | Message::ProbeWriteTargets { .. } | Message::BuildManifest { .. } | Message::AcquireGitSource { .. } | Message::OpenLocalSource { .. } @@ -276,6 +310,9 @@ async fn main() -> Result<(), Box> { .checked_add(1) .ok_or("inbound transfer handle space exhausted")?; prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(preparation.deadline_millis), transfer_id, total_bytes: preparation.total_bytes, sha256: preparation.sha256.clone(), @@ -373,6 +410,9 @@ async fn main() -> Result<(), Box> { .checked_add(1) .ok_or("inbound transfer handle space exhausted")?; prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(preparation.deadline_millis), transfer_id, total_bytes: preparation.total_bytes, sha256: preparation.sha256, @@ -414,6 +454,9 @@ async fn main() -> Result<(), Box> { .checked_add(1) .ok_or("inbound transfer handle space exhausted")?; prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), transfer_id, total_bytes, sha256, @@ -456,6 +499,9 @@ async fn main() -> Result<(), Box> { .checked_add(1) .ok_or("inbound transfer handle space exhausted")?; prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), transfer_id, total_bytes, sha256, @@ -539,6 +585,9 @@ async fn main() -> Result<(), Box> { .checked_add(1) .ok_or("inbound transfer handle space exhausted")?; prepared_inbound = Some(PreparedInbound { + owner_request_id: request_id, + deadline: Instant::now() + + Duration::from_millis(MAX_REQUEST_DEADLINE_MILLIS), transfer_id, total_bytes, sha256, @@ -582,6 +631,7 @@ async fn main() -> Result<(), Box> { )?; active_inbound = Some(ActiveInbound { owner_request_id, + deadline: prepared.deadline, path: prepared.path, action: prepared.action, transfer, @@ -665,28 +715,36 @@ async fn main() -> Result<(), Box> { if let Some(cancelled) = active.get(&target_request_id) { cancelled.store(true, Ordering::Release); } + if prepared_inbound + .as_ref() + .is_some_and(|inbound| inbound.owner_request_id == target_request_id) + { + let inbound = prepared_inbound.take().unwrap(); + drop(inbound.file); + finish_inbound_with_error( + &writer, + target_request_id, + "cancelled", + &payloads, + inbound.path, + inbound.action, + ) + .await?; + } if active_inbound .as_ref() .is_some_and(|inbound| inbound.owner_request_id == target_request_id) { let inbound = active_inbound.take().unwrap(); - let _ = tokio::fs::remove_file(inbound.path).await; - match inbound.action { - InboundAction::Blob { upload_id, .. } - | InboundAction::Manifest { upload_id } => { - payloads.lock().await.abort_upload(upload_id); - send_error(&writer, target_request_id, "cancelled", "payloadUpload").await?; - } - InboundAction::Mutation { .. } => { - send_error(&writer, target_request_id, "cancelled", "mutation").await?; - } - InboundAction::Document { .. } => { - send_error(&writer, target_request_id, "cancelled", "documentWrite").await?; - } - InboundAction::Library { .. } => { - send_error(&writer, target_request_id, "cancelled", "library").await?; - } - } + finish_inbound_with_error( + &writer, + target_request_id, + "cancelled", + &payloads, + inbound.path, + inbound.action, + ) + .await?; } } Message::Shutdown => { @@ -915,6 +973,16 @@ async fn execute_business_request( let result = timeout_result(deadline, task, &cancelled, "projection").await?; send_payload_result(request_id, result, request.cancelled, writer).await?; } + Message::ProbeWriteTargets { request: intent } => { + let deadline = Duration::from_millis(intent.deadline_millis); + let cancelled = Arc::clone(&request.cancelled); + let operation_cancelled = Arc::clone(&cancelled); + let task = tokio::task::spawn_blocking(move || { + execute_write_probe(intent, || operation_cancelled.load(Ordering::Acquire)) + }); + let result = finish_write_probe(deadline, task, &cancelled).await?; + send_payload_result(request_id, result, request.cancelled, writer).await?; + } Message::BuildManifest { request: intent } => { let deadline = Duration::from_millis(intent.deadline_millis); let cancelled = Arc::clone(&request.cancelled); @@ -2172,6 +2240,27 @@ async fn timeout_result( } } +async fn finish_write_probe( + deadline: Duration, + mut task: tokio::task::JoinHandle< + Result, + >, + cancelled: &Arc, +) -> Result, String> { + match tokio::time::timeout(deadline, &mut task).await { + Ok(joined) => joined.map_err(|error| error.to_string()), + Err(_) => { + cancelled.store(true, Ordering::Release); + // A terminal response must follow the last filesystem side effect. + let _ = task.await.map_err(|error| error.to_string())?; + Ok(Err(RequestError { + code: "deadlineExceeded", + phase: "writePreflight", + })) + } + } +} + async fn send_payload_result( request_id: u64, result: Result, @@ -2237,6 +2326,27 @@ async fn discard_inbound( } } +async fn finish_inbound_with_error( + writer: &ProtocolWriter, + request_id: u64, + code: &'static str, + payloads: &Arc>, + path: PathBuf, + action: InboundAction, +) -> Result<(), environment_protocol::WriterError> { + let _ = tokio::fs::remove_file(path).await; + let phase = match action { + InboundAction::Blob { upload_id, .. } | InboundAction::Manifest { upload_id } => { + payloads.lock().await.abort_upload(upload_id); + "payloadUpload" + } + InboundAction::Mutation { .. } => "mutation", + InboundAction::Document { .. } => "documentWrite", + InboundAction::Library { .. } => "library", + }; + send_error(writer, request_id, code, phase).await +} + fn cancel_all(active: &HashMap>) { for cancelled in active.values() { cancelled.store(true, Ordering::Release); @@ -2255,3 +2365,46 @@ fn effective_user_id() -> u32 { fn effective_user_id() -> u32 { 0 } + +#[cfg(test)] +mod write_probe_tests { + use super::*; + + #[tokio::test] + async fn write_probe_timeout_waits_for_inflight_filesystem_work() { + let cancelled = Arc::new(AtomicBool::new(false)); + let observed_cancelled = cancelled.clone(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let task = tokio::task::spawn_blocking(move || { + started.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(environment_protocol::WriteProbeResponse { checked_count: 1 }) + }); + ready.await.unwrap(); + let result = + tokio::spawn(async move { finish_write_probe(Duration::ZERO, task, &cancelled).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while !observed_cancelled.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let finished_before_work = result.is_finished(); + release.send(()).unwrap(); + let outcome = result.await.unwrap().unwrap(); + + assert!( + !finished_before_work, + "a write probe cannot acknowledge timeout while filesystem work is still running" + ); + assert!(matches!( + outcome, + Err(RequestError { + code: "deadlineExceeded", + .. + }) + )); + } +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/library.rs b/src-tauri/crates/wsl-environment-worker/tests/library.rs index 98aab1c0..16890824 100644 --- a/src-tauri/crates/wsl-environment-worker/tests/library.rs +++ b/src-tauri/crates/wsl-environment-worker/tests/library.rs @@ -106,3 +106,59 @@ fn manager_commits_a_member_from_an_existing_payload_handle() { ); assert_eq!(manager.read_catalog().unwrap().bytes, second); } + +#[test] +fn manager_deletes_a_library_directory_and_catalog_as_one_intent() { + let home = tempfile::tempdir().unwrap(); + let manager = LibraryManager::new(home.path().to_path_buf()); + let payloads = PayloadManager::new(home.path().join("payloads")).unwrap(); + let first = br#"{"schemaVersion":1,"libraries":[{"id":"lib-1"}]}"#.to_vec(); + let catalog_revision = manager + .execute( + LibraryOperationRequest { + operation_id: "save-1".to_string(), + expected_catalog_revision: None, + catalog_bytes: first, + action: LibraryOperationAction::SaveCatalog { + library_ids: vec!["lib-1".to_string()], + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + let destination = home + .path() + .join(".skill-deck/skill-libraries/libraries/lib-1"); + std::fs::write(destination.join("skills/SKILL.md"), b"member").unwrap(); + let target = project_targets(&ProjectionRequest { + destinations: vec![destination.clone()], + }) + .unwrap() + .targets + .pop() + .unwrap(); + let second = br#"{"schemaVersion":1,"libraries":[]}"#.to_vec(); + + manager + .execute( + LibraryOperationRequest { + operation_id: "delete-library-1".to_string(), + expected_catalog_revision: Some(catalog_revision), + catalog_bytes: second.clone(), + action: LibraryOperationAction::DeleteLibrary { + library_id: "lib-1".to_string(), + expected_anchor_device: target.anchor_device, + expected_anchor_inode: target.anchor_inode, + expected_fingerprint: fingerprint_path(&destination).unwrap(), + expected_content_hash: None, + }, + deadline_millis: 30_000, + }, + &payloads, + ) + .unwrap(); + + assert!(!destination.exists()); + assert_eq!(manager.read_catalog().unwrap().bytes, second); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/runtime.rs b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs index 12cf28be..279e6ad4 100644 --- a/src-tauri/crates/wsl-environment-worker/tests/runtime.rs +++ b/src-tauri/crates/wsl-environment-worker/tests/runtime.rs @@ -1,10 +1,12 @@ use std::io::Write; #[cfg(target_os = "linux")] -use environment_protocol::{InspectionEntryKind, InspectionRequest, InspectionRoot}; +use environment_protocol::{ + InspectionEntryKind, InspectionRequest, InspectionRoot, WriteProbeRequest, +}; use environment_protocol::{Message, PathKind}; #[cfg(target_os = "linux")] -use wsl_environment_worker::execute_inspection; +use wsl_environment_worker::{execute_inspection, execute_write_probe}; use wsl_environment_worker::{file_sha256, Dispatch, WorkerIdentity, WorkerRuntime}; fn identity(home: &str) -> WorkerIdentity { @@ -133,3 +135,25 @@ fn inspection_adapter_executes_the_shared_engine_and_returns_raw_path_bytes() { fact.relative_path == vec![b's', b'k', 0x80] && fact.kind == InspectionEntryKind::File })); } + +#[cfg(target_os = "linux")] +#[test] +fn write_probe_adapter_rejects_an_already_read_only_parent() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("read-only"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); + + let result = execute_write_probe( + WriteProbeRequest { + destinations: vec![parent.join("demo").to_string_lossy().into_owned()], + deadline_millis: 1_000, + }, + || false, + ); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); +} diff --git a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs index c48208a1..f252a4bf 100644 --- a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs +++ b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs @@ -999,6 +999,42 @@ async fn worker_writes_one_document_atomically_over_stdio() { writer .send_control(WireRecord::Control(Envelope { request_id: 6, + message: Message::Cancel { + target_request_id: 5, + }, + })) + .await + .unwrap(); + let replacement_path = home.path().join(".skill-deck/replacement.json"); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 7, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: replacement_path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: format!("sha256:{:x}", Sha256::digest(bytes)), + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + let cancelled = next_message(&mut reader).await; + assert_eq!(cancelled.request_id, 5); + assert!(matches!( + cancelled.message, + Message::Error { ref code, .. } if code == "cancelled" + )); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 8, message: Message::Shutdown, })) .await @@ -1011,7 +1047,7 @@ async fn worker_writes_one_document_atomically_over_stdio() { .unwrap() .success()); assert!(!abandoned_path.exists()); - for request_id in [2_u64, 3, 5] { + for request_id in [2_u64, 3, 5, 7] { assert!(!std::path::PathBuf::from(format!( "/tmp/.skill-deck-document-request-{worker_pid}-{request_id}" )) @@ -1019,6 +1055,107 @@ async fn worker_writes_one_document_atomically_over_stdio() { } } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn prepared_document_expires_without_follow_up_traffic() { + use sha2::{Digest, Sha256}; + + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let path = home.path().join(".skill-deck/expired.json"); + let bytes = br#"{"value":1}"#; + let digest = format!("sha256:{:x}", Sha256::digest(bytes)); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::HandshakeResult { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest.clone(), + deadline_millis: 25, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + + let expired = timeout(Duration::from_secs(2), next_message(&mut reader)) + .await + .expect("prepared document must expire without another request"); + assert_eq!(expired.request_id, 2); + assert!(matches!( + expired.message, + Message::Error { ref code, .. } if code == "deadlineExceeded" + )); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::PrepareDocumentWrite { + request: DocumentWritePreparation { + path: path.to_string_lossy().into_owned(), + expected_revision: None, + total_bytes: bytes.len() as u64, + sha256: digest, + deadline_millis: 30_000, + }, + }, + })) + .await + .unwrap(); + assert!(matches!( + next_message(&mut reader).await.message, + Message::TransferReady { .. } + )); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 4, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); + assert!(!path.exists()); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn worker_executes_one_library_catalog_operation_over_stdio() { diff --git a/src-tauri/src/application/mutation/coordinator.rs b/src-tauri/src/application/mutation/coordinator.rs index d3e3820c..95fee0d4 100644 --- a/src-tauri/src/application/mutation/coordinator.rs +++ b/src-tauri/src/application/mutation/coordinator.rs @@ -14,6 +14,7 @@ use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::PayloadId; use crate::environment::types::SkillLocationRef; use crate::error::AppError; +use crate::storage::atomic_document::DocumentWriteFailure; use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -27,7 +28,8 @@ pub struct MutationUnitProgress { pub type MutationUnitObserver<'a> = Arc; -pub trait PreparedEntryExecutor: Send + Sync { +#[cfg(test)] +pub trait PreparedEntryTestDriver: Send + Sync { type Staged: Send; #[cfg(test)] @@ -59,7 +61,7 @@ pub trait PreparedLockCommitter: Send + Sync { fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result>; + ) -> BoxFuture<'a, Result>; } #[derive(Debug, Clone)] @@ -102,7 +104,7 @@ impl PhasedUnitExecutor { #[cfg(test)] impl PreparedUnitExecutor for PhasedUnitExecutor where - E: PreparedEntryExecutor, + E: PreparedEntryTestDriver, L: PreparedLockCommitter, { type Prepared = E::Staged; @@ -128,7 +130,12 @@ where self.entries.swap(&mut prepared).await?; self.entries.verify(&prepared).await?; match lock { - Some(lock) => self.locks.commit(lock).await.map(Some), + Some(lock) => self + .locks + .commit(lock) + .await + .map(Some) + .map_err(DocumentWriteFailure::into_error), None => Ok(None), } } @@ -265,6 +272,24 @@ where return results; } + if plan.kind == crate::core::mutation::MutationKind::ManageLibraries + && staged_units.values().any(Result::is_err) + { + let mut results = Vec::with_capacity(plan.units.len()); + for (index, unit) in plan.units.iter().enumerate() { + match staged_units + .remove(&index) + .expect("every unit has a preflight result") + { + Ok((_prepared, _)) => results.push(not_run(unit, AppError::StaleTarget)), + Err(error) => { + results.push(failed_result(unit, error_for_preflight(&error), false)); + } + } + } + return results; + } + let mut results = Vec::with_capacity(plan.units.len()); let mut blocked_targets = BTreeSet::new(); for index in 0..plan.units.len() { @@ -351,7 +376,7 @@ where #[cfg(test)] impl MutationCoordinator, R> where - E: PreparedEntryExecutor, + E: PreparedEntryTestDriver, L: PreparedLockCommitter, R: RuntimeRevisionSource, { @@ -928,6 +953,30 @@ mod tests { assert!(prepare_third < execute_first); } + #[tokio::test] + async fn library_application_prepare_failure_stops_the_entire_scope_before_writes() { + let log = Arc::new(Mutex::new(Vec::new())); + let mut plan = plan(vec![ + unit("first", key("first")), + unit("second", key("second")), + unit("third", key("third")), + ]); + plan.kind = crate::core::mutation::MutationKind::ManageLibraries; + + let results = coordinator(Failure::PrepareSecond, log.clone()) + .execute(plan, CancellationSignal::default()) + .await; + + assert_eq!(results[0].status, MutationUnitStatus::NotRun); + assert_eq!(results[1].status, MutationUnitStatus::Failed); + assert_eq!(results[2].status, MutationUnitStatus::NotRun); + assert!(!log + .lock() + .unwrap() + .iter() + .any(|entry| entry.starts_with("execute:"))); + } + #[tokio::test] async fn cancellation_after_prepare_stops_before_every_transaction() { let log = Arc::new(Mutex::new(Vec::new())); diff --git a/src-tauri/src/environment/native/entry.rs b/src-tauri/src/environment/native/entry.rs index a34c5ae9..1be3d910 100644 --- a/src-tauri/src/environment/native/entry.rs +++ b/src-tauri/src/environment/native/entry.rs @@ -108,6 +108,52 @@ pub fn stage_entry_set(intents: &[NativeEntryIntent]) -> Result Result<(), AppError> { + preflight_initial_intents(intents)?; + for intent in intents { + if matches!(intent.action, NativeEntryAction::Keep) { + continue; + } + let mut parent = intent + .destination + .parent() + .ok_or_else(|| unsafe_destination(&intent.destination))?; + while matches!(fs::symlink_metadata(parent), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + { + parent = parent + .parent() + .ok_or_else(|| unsafe_destination(&intent.destination))?; + } + if !fs::metadata(parent).is_ok_and(|metadata| metadata.is_dir()) { + return Err(unsafe_destination(&intent.destination)); + } + let probe = parent.join(format!(".skill-deck-preflight-{}", Uuid::new_v4().simple())); + let renamed = parent.join(format!( + ".skill-deck-preflight-renamed-{}", + Uuid::new_v4().simple() + )); + let result = (|| { + match &intent.action { + NativeEntryAction::Symlink { target } => { + create_directory_link(target, &probe)?; + } + NativeEntryAction::Keep => unreachable!(), + NativeEntryAction::Materialize { .. } | NativeEntryAction::Remove => { + fs::create_dir(&probe)?; + } + } + fs::rename(&probe, &renamed)?; + remove_entry_no_follow(&renamed) + })(); + if result.is_err() { + let _ = remove_entry_no_follow(&probe); + let _ = remove_entry_no_follow(&renamed); + } + result?; + } + Ok(()) +} + pub fn planned_recovery_paths(entries: &NativeEntrySet) -> Vec { entries .entries diff --git a/src-tauri/src/environment/native/materialize.rs b/src-tauri/src/environment/native/materialize.rs index 1561f24a..537cb4ba 100644 --- a/src-tauri/src/environment/native/materialize.rs +++ b/src-tauri/src/environment/native/materialize.rs @@ -5,9 +5,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; +#[cfg(test)] +use crate::application::mutation::coordinator::PreparedEntryTestDriver; use crate::application::mutation::coordinator::{ - BoxFuture, PreparedEntryExecutor, PreparedLockCommitter, PreparedUnitExecutor, - UnitTransactionReceipt, + BoxFuture, PreparedLockCommitter, PreparedUnitExecutor, UnitTransactionReceipt, }; use crate::application::mutation::plan::{ ExecutionUnit, PreparedEntryAction, PreparedEntryMutation, @@ -17,9 +18,9 @@ use crate::application::payload_session::{PayloadLocalSource, PinnedPayloadLease use crate::core::mutation::CancellationSignal; use crate::core::skill_payload::{PayloadId, SkillPayload}; use crate::environment::native::entry::{ - cleanup_entry_set, planned_recovery_paths, recheck_entry_set, restore_entry_set, - stage_entry_set, swap_entry_set, verify_entry_set, NativeEntryAction, NativeEntryIntent, - NativeEntrySet, + cleanup_entry_set, planned_recovery_paths, preflight_entry_writes, recheck_entry_set, + restore_entry_set, stage_entry_set, swap_entry_set, verify_entry_set, NativeEntryAction, + NativeEntryIntent, NativeEntrySet, }; use crate::environment::recovery::{ RecoveryEntryPhase, RecoveryMarker, RecoveryMarkerEntry, RecoveryMarkerKind, RecoveryMarkerRef, @@ -29,11 +30,15 @@ use crate::environment::runtime::ExecutionBackend; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::{AppError, RecoveryResourceId}; use crate::models::InstallMode; +use crate::storage::atomic_document::{DocumentWriteFailure, PublicationState}; use crate::storage::lock_plan::PreparedLockMutation; pub struct NativePreparedEntrySet { entries: NativeEntrySet, recovery: Option, + // In-memory safety latch also protects evidence when persisting the + // RecoveryRequired marker itself fails. + retain_recovery: bool, } struct NativePreparedRecovery { @@ -112,9 +117,11 @@ where if cancellation.is_cancelled() { return Err(AppError::MutationCancelled); } + let intents = prepare_native_mutations(unit, &loaded, self.entries.backend.clone())?; + preflight_entry_writes(&intents)?; Ok(PreparedNativeUnit { unit: unit.clone(), - intents: prepare_native_mutations(unit, &loaded, self.entries.backend.clone())?, + intents, }) }) } @@ -129,9 +136,10 @@ where if cancellation.is_cancelled() { return Err(AppError::MutationCancelled); } - let entries = tokio::task::spawn_blocking(move || stage_entry_set(&prepared.intents)) - .await - .map_err(native_task_error)??; + // Staging creates directories and temporary entries. Keep it in the + // transaction future so dropping the caller cannot detach writes + // after RuntimeAdmission has released its permit. + let entries = stage_entry_set(&prepared.intents)?; let recovery = if planned_recovery_paths(&entries).is_empty() { None } else { @@ -167,16 +175,39 @@ where recovery_ref, }) }; - let mut staged = NativePreparedEntrySet { entries, recovery }; + let mut staged = NativePreparedEntrySet { + entries, + recovery, + retain_recovery: false, + }; + enum TransactionFailure { + Entry(AppError), + Lock(DocumentWriteFailure), + } + let transaction = async { if cancellation.is_cancelled() { - return Err(AppError::MutationCancelled); + return Err(TransactionFailure::Entry(AppError::MutationCancelled)); } - self.entries.recheck_entries(&staged).await?; - self.entries.swap(&mut staged).await?; - self.entries.verify(&staged).await?; + self.entries + .recheck_entries(&staged) + .await + .map_err(TransactionFailure::Entry)?; + self.entries + .swap(&mut staged) + .await + .map_err(TransactionFailure::Entry)?; + self.entries + .verify(&staged) + .await + .map_err(TransactionFailure::Entry)?; match lock { - Some(lock) => self.locks.commit(lock).await.map(Some), + Some(lock) => self + .locks + .commit(lock) + .await + .map(Some) + .map_err(TransactionFailure::Lock), None => Ok(None), } } @@ -193,10 +224,39 @@ where }], }), }, - Err(primary) => { - let restore = self.entries.restore(&mut staged).await; + Err(TransactionFailure::Lock( + failure @ DocumentWriteFailure { + publication: + PublicationState::PublishedUnconfirmed | PublicationState::OutcomeUnknown, + .. + }, + )) => staged + .recovery_required(format!( + "lock publication is not confirmed: {}", + failure.error + )) + .await + .map(|()| unreachable!("recovery_required always returns an error")), + Err(TransactionFailure::Entry(primary @ AppError::RecoveryRequired { .. })) => { + Err(primary) + } + Err(TransactionFailure::Entry(primary)) + | Err(TransactionFailure::Lock(DocumentWriteFailure { + error: primary, + publication: PublicationState::NotPublished, + .. + })) => { + // swap may already have attempted and failed to restore. + // Do not retry a destructive compensation after that + // boundary, or turn retained evidence into cleanup work. + if staged.retain_recovery { + return Err(primary); + } + // A failed restore leaves the only recoverable copy in + // backup. Cleanup must not relabel or delete that evidence. + self.entries.restore(&mut staged).await?; let _ = self.entries.cleanup(staged).await; - Err(restore.err().unwrap_or(primary)) + Err(primary) } } }) @@ -233,16 +293,14 @@ impl NativePreparedEntryExecutor { } } -impl PreparedEntryExecutor for NativePreparedEntryExecutor { - type Staged = NativePreparedEntrySet; - +impl NativePreparedEntryExecutor { #[cfg(test)] fn stage<'a>( &'a self, unit: &'a ExecutionUnit, payloads: &'a BTreeMap, cancellation: CancellationSignal, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { let mut loaded = BTreeMap::new(); for entry in unit @@ -279,9 +337,7 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { return Err(AppError::MutationCancelled); } let intents = prepare_native_mutations(unit, &loaded, self.backend.clone())?; - let entries = tokio::task::spawn_blocking(move || stage_entry_set(&intents)) - .await - .map_err(native_task_error)??; + let entries = stage_entry_set(&intents)?; let recovery = if planned_recovery_paths(&entries).is_empty() { None } else { @@ -317,13 +373,17 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { recovery_ref, }) }; - Ok(NativePreparedEntrySet { entries, recovery }) + Ok(NativePreparedEntrySet { + entries, + recovery, + retain_recovery: false, + }) }) } fn recheck_entries<'a>( &'a self, - staged: &'a Self::Staged, + staged: &'a NativePreparedEntrySet, ) -> BoxFuture<'a, Result<(), AppError>> { let entries = staged.entries.clone(); Box::pin(async move { @@ -333,7 +393,10 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn swap<'a>( + &'a self, + staged: &'a mut NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { Box::pin(async move { match swap_entry_set(&mut staged.entries) { Ok(()) => { @@ -350,7 +413,10 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn verify<'a>( + &'a self, + staged: &'a NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { let entries = staged.entries.clone(); Box::pin(async move { tokio::task::spawn_blocking(move || verify_entry_set(&entries)) @@ -365,13 +431,18 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { }) } - fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + fn restore<'a>( + &'a self, + staged: &'a mut NativePreparedEntrySet, + ) -> BoxFuture<'a, Result<(), AppError>> { Box::pin(async move { match restore_entry_set(&mut staged.entries) { Ok(()) => { staged .update_recovery(RecoveryMarkerKind::CleanupOnly, None) - .await + .await?; + staged.retain_recovery = false; + Ok(()) } Err(error) => staged.recovery_required(error.to_string()).await, } @@ -380,9 +451,22 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { fn cleanup<'a>( &'a self, - staged: Self::Staged, + staged: NativePreparedEntrySet, ) -> BoxFuture<'a, Result, AppError>> { Box::pin(async move { + if staged.retain_recovery { + return Err(match &staged.recovery { + Some(recovery) => AppError::RecoveryRequired { + recovery_resource_id: recovery.recovery_ref.resource_id.clone(), + message: + "native recovery evidence cannot be cleaned before a confirmed restore" + .to_string(), + }, + None => AppError::RestoreFailed { + message: "native restore is not confirmed".to_string(), + }, + }); + } staged .update_recovery(RecoveryMarkerKind::CleanupOnly, None) .await?; @@ -416,6 +500,46 @@ impl PreparedEntryExecutor for NativePreparedEntryExecutor { } } +#[cfg(test)] +impl PreparedEntryTestDriver for NativePreparedEntryExecutor { + type Staged = NativePreparedEntrySet; + + fn stage<'a>( + &'a self, + unit: &'a ExecutionUnit, + payloads: &'a BTreeMap, + cancellation: CancellationSignal, + ) -> BoxFuture<'a, Result> { + NativePreparedEntryExecutor::stage(self, unit, payloads, cancellation) + } + + fn recheck_entries<'a>( + &'a self, + staged: &'a Self::Staged, + ) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::recheck_entries(self, staged) + } + + fn swap<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::swap(self, staged) + } + + fn verify<'a>(&'a self, staged: &'a Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::verify(self, staged) + } + + fn restore<'a>(&'a self, staged: &'a mut Self::Staged) -> BoxFuture<'a, Result<(), AppError>> { + NativePreparedEntryExecutor::restore(self, staged) + } + + fn cleanup<'a>( + &'a self, + staged: Self::Staged, + ) -> BoxFuture<'a, Result, AppError>> { + NativePreparedEntryExecutor::cleanup(self, staged) + } +} + fn native_task_error(error: tokio::task::JoinError) -> AppError { AppError::ExecutionFailed { message: format!("native mutation task failed: {error}"), @@ -454,7 +578,8 @@ impl NativePreparedEntrySet { Ok(()) } - async fn recovery_required(&self, message: String) -> Result<(), AppError> { + async fn recovery_required(&mut self, message: String) -> Result<(), AppError> { + self.retain_recovery = true; let Some(recovery) = &self.recovery else { return Err(AppError::RestoreFailed { message }); }; @@ -654,12 +779,12 @@ fn validate_native_entry( mod tests { use std::collections::BTreeMap; use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tempfile::tempdir; use super::*; - use crate::application::mutation::coordinator::PreparedEntryExecutor; use crate::application::mutation::plan::{ ExecutionUnit, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, RuntimeRevisions, @@ -1109,4 +1234,498 @@ mod tests { ExecutionBackend::NativeUnix } } + + struct FailingLockCommitter { + destination: PathBuf, + block_restore: bool, + } + + impl PreparedLockCommitter for FailingLockCommitter { + fn commit<'a>( + &'a self, + _mutation: &'a PreparedLockMutation, + ) -> BoxFuture< + 'a, + Result< + crate::storage::lock_plan::LockCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + if self.block_restore { + // The production entry set has already moved the original + // directory into its backup. A nonempty replacement makes + // the restore rename fail without deleting that backup. + fs::create_dir(&self.destination) + .map_err(AppError::from) + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::not_published, + )?; + fs::write(self.destination.join("external.txt"), b"external") + .map_err(AppError::from) + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::not_published, + )?; + } + Err( + crate::storage::atomic_document::DocumentWriteFailure::not_published( + AppError::ExecutionFailed { + message: "injected lock failure".to_string(), + }, + ), + ) + }) + } + } + + struct PublishedUnconfirmedDocumentIo; + + struct MarkerUpdateUnconfirmedDocumentIo { + writes: AtomicUsize, + } + + impl crate::storage::atomic_document::AtomicDocumentIo for MarkerUpdateUnconfirmedDocumentIo { + fn observe<'a>( + &'a self, + target: &'a ResourceLocator, + max_bytes: u64, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result, + > { + crate::environment::native::atomic_file::NativeAtomicDocumentIo + .observe(target, max_bytes) + } + + fn replace<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + bytes: Vec, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result< + crate::storage::atomic_document::DocumentCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + let receipt = crate::environment::native::atomic_file::NativeAtomicDocumentIo + .replace(target, expected, bytes) + .await?; + if self.writes.fetch_add(1, Ordering::SeqCst) > 0 { + return Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "injected marker sync failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }); + } + Ok(receipt) + }) + } + + fn remove<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result<(), crate::storage::atomic_document::DocumentWriteFailure>, + > { + crate::environment::native::atomic_file::NativeAtomicDocumentIo.remove(target, expected) + } + } + + impl crate::storage::atomic_document::AtomicDocumentIo for PublishedUnconfirmedDocumentIo { + fn observe<'a>( + &'a self, + target: &'a ResourceLocator, + max_bytes: u64, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result, + > { + Box::pin(async move { + let max_bytes = usize::try_from(max_bytes).unwrap(); + let bytes = environment_engine::atomic_document::read_optional_bounded( + Path::new(&target.native_path), + max_bytes, + )?; + Ok(crate::storage::atomic_document::DocumentSnapshot { + bytes, + generation: None, + }) + }) + } + + fn replace<'a>( + &'a self, + target: &'a ResourceLocator, + expected: crate::storage::atomic_document::DocumentSnapshot, + bytes: Vec, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result< + crate::storage::atomic_document::DocumentCommitReceipt, + crate::storage::atomic_document::DocumentWriteFailure, + >, + > { + Box::pin(async move { + environment_engine::atomic_document::replace_if_unchanged( + Path::new(&target.native_path), + expected.bytes.as_deref(), + &bytes, + ) + .map_err(crate::storage::atomic_document::DocumentWriteFailure::from_engine)?; + Err(crate::storage::atomic_document::DocumentWriteFailure { + error: AppError::Io { + message: "injected parent sync failure".to_string(), + }, + phase: crate::storage::atomic_document::WritePhase::Confirming, + publication: + crate::storage::atomic_document::PublicationState::PublishedUnconfirmed, + }) + }) + } + + fn remove<'a>( + &'a self, + _target: &'a ResourceLocator, + _expected: crate::storage::atomic_document::DocumentSnapshot, + ) -> crate::storage::atomic_document::IoFuture< + 'a, + Result<(), crate::storage::atomic_document::DocumentWriteFailure>, + > { + Box::pin(async { panic!("materialization test does not remove documents") }) + } + } + + #[tokio::test] + async fn native_unit_retains_backup_and_marker_when_restore_fails() { + exercise_native_restore_failure(true).await; + } + + #[tokio::test] + async fn native_unit_cleans_after_confirmed_successful_restore() { + exercise_native_restore_failure(false).await; + } + + #[tokio::test] + async fn native_unit_does_not_restore_entries_after_an_unconfirmed_lock_publish() { + use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; + use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState}; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "unconfirmed-lock", + recovery.clone(), + ), + crate::runtime::plan_runner::RuntimeLockCommitter::with_io(Arc::new( + PublishedUnconfirmedDocumentIo, + )), + ); + let lock_path = root.join("skills-lock.json"); + let lock = PreparedLockMutation { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: lock_path.to_string_lossy().into_owned(), + }, + legacy_target: None, + schema: LockSchema::Project, + entry: LockEntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected: LockExpectedState::capture( + &LosslessLockDocument::empty(LockSchema::Project), + ["demo"], + std::iter::empty::<&str>(), + ), + }; + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + + let error = executor + .execute(prepared, Some(&lock), CancellationSignal::default()) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::RecoveryRequired { .. })); + assert!(!canonical.exists()); + let lock: serde_json::Value = + serde_json::from_slice(&fs::read(lock_path).unwrap()).unwrap(); + assert_eq!(lock["version"], 1); + let markers = recovery.enumerate().await.unwrap(); + let [RecoveryMarkerLoad::Valid { marker, .. }] = markers.as_slice() else { + panic!("unconfirmed lock publication must retain recovery evidence"); + }; + assert_eq!(marker.kind, RecoveryMarkerKind::RecoveryRequired); + let backup = marker + .entries + .iter() + .find(|entry| entry.destination.native_path == canonical.to_string_lossy()) + .unwrap() + .backup + .as_ref() + .unwrap(); + assert_eq!( + fs::read(Path::new(&backup.native_path).join("SKILL.md")).unwrap(), + b"old" + ); + } + + #[tokio::test] + async fn native_unit_does_not_restore_after_an_unconfirmed_marker_publish() { + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new( + NativeRecoveryMarkerStore::with_io( + root.join("recovery"), + Arc::new(MarkerUpdateUnconfirmedDocumentIo { + writes: AtomicUsize::new(0), + }), + ) + .unwrap(), + ); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "unconfirmed-marker", + recovery.clone(), + ), + FailingLockCommitter { + destination: canonical.clone(), + block_restore: false, + }, + ); + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + + let error = executor + .execute(prepared, None, CancellationSignal::default()) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::RecoveryRequired { .. })); + assert!(!canonical.exists()); + assert_eq!(recovery.enumerate().await.unwrap().len(), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn native_prepare_rejects_a_target_parent_that_is_already_read_only() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + fs::create_dir_all(agent.parent().unwrap()).unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Keep), + mutation( + &agent, + PreparedEntryAction::Link { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: canonical.to_string_lossy().into_owned(), + }, + }, + ), + ); + fs::set_permissions(agent.parent().unwrap(), fs::Permissions::from_mode(0o500)).unwrap(); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new(native_backend(), "read-only-preflight", recovery), + FailingLockCommitter { + destination: canonical, + block_restore: false, + }, + ); + + let result = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + + fs::set_permissions(agent.parent().unwrap(), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + assert!(!agent.exists()); + } + + #[cfg(windows)] + #[tokio::test] + async fn native_prepare_rejects_a_parent_that_denies_child_rename() { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_GENERIC_READ, FILE_SHARE_READ, + OPEN_EXISTING, + }; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + fs::create_dir_all(agent.parent().unwrap()).unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Keep), + mutation( + &agent, + PreparedEntryAction::Link { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: canonical.to_string_lossy().into_owned(), + }, + }, + ), + ); + let wide = agent + .parent() + .unwrap() + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new(native_backend(), "locked-parent-preflight", recovery), + FailingLockCommitter { + destination: canonical, + block_restore: false, + }, + ); + + let result = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + + unsafe { CloseHandle(handle) }; + assert!(result.is_err()); + assert!(!agent.exists()); + } + + async fn exercise_native_restore_failure(block_restore: bool) { + use crate::core::lossless_lock::LockSchema; + use crate::storage::lock_plan::{LockEntryMutation, LockExpectedState}; + + let temp = tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let canonical = root.join("shared/demo"); + let agent = root.join("agent/demo"); + fs::create_dir_all(&canonical).unwrap(); + fs::create_dir_all(&agent).unwrap(); + fs::write(canonical.join("SKILL.md"), b"old").unwrap(); + let unit = unit( + mutation(&canonical, PreparedEntryAction::Remove), + mutation(&agent, PreparedEntryAction::Keep), + ); + let recovery = Arc::new(NativeRecoveryMarkerStore::new(root.join("recovery")).unwrap()); + let executor = NativePreparedUnitExecutor::new( + NativePreparedEntryExecutor::new( + native_backend(), + "restore-regression", + recovery.clone(), + ), + FailingLockCommitter { + destination: canonical.clone(), + block_restore, + }, + ); + let lock = PreparedLockMutation { + target: ResourceLocator { + environment: EnvironmentRef::Native, + native_path: root.join("skills-lock.json").to_string_lossy().into_owned(), + }, + legacy_target: None, + schema: LockSchema::Project, + entry: LockEntryMutation::Remove { + key: "demo".to_string(), + }, + root_replacements: BTreeMap::new(), + expected: LockExpectedState { + entry_snapshots: BTreeMap::new(), + root_snapshots: BTreeMap::new(), + }, + }; + let prepared = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await + .unwrap(); + let error = executor + .execute(prepared, Some(&lock), CancellationSignal::default()) + .await + .unwrap_err(); + let markers = recovery.enumerate().await.unwrap(); + if block_restore { + assert!(matches!(error, AppError::RecoveryRequired { .. })); + let [RecoveryMarkerLoad::Valid { marker, .. }] = markers.as_slice() else { + panic!("restore failure must retain a valid recovery marker"); + }; + assert_eq!(marker.kind, RecoveryMarkerKind::RecoveryRequired); + let backup = marker + .entries + .iter() + .find(|entry| entry.destination.native_path == canonical.to_string_lossy()) + .unwrap() + .backup + .as_ref() + .unwrap(); + assert_eq!( + fs::read(Path::new(&backup.native_path).join("SKILL.md")).unwrap(), + b"old" + ); + assert_eq!( + fs::read(canonical.join("external.txt")).unwrap(), + b"external" + ); + } else { + assert!(matches!(error, AppError::ExecutionFailed { .. })); + assert!(markers.is_empty()); + assert_eq!(fs::read(canonical.join("SKILL.md")).unwrap(), b"old"); + } + } } diff --git a/src-tauri/src/environment/native/recovery.rs b/src-tauri/src/environment/native/recovery.rs index 4cbd1d61..a3922623 100644 --- a/src-tauri/src/environment/native/recovery.rs +++ b/src-tauri/src/environment/native/recovery.rs @@ -9,14 +9,14 @@ use crate::environment::recovery::{ }; use crate::environment::types::{EnvironmentRef, ResourceLocator}; use crate::error::{AppError, RecoveryResourceId}; -use crate::storage::atomic_document::AtomicDocumentIo; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure, PublicationState}; const MARKER_FILE: &str = "recovery.json"; #[derive(Clone)] pub struct NativeRecoveryMarkerStore { root: PathBuf, - io: NativeAtomicDocumentIo, + io: std::sync::Arc, } impl NativeRecoveryMarkerStore { @@ -24,7 +24,19 @@ impl NativeRecoveryMarkerStore { fs::create_dir_all(root.as_ref())?; Ok(Self { root: fs::canonicalize(root.as_ref())?, - io: NativeAtomicDocumentIo, + io: std::sync::Arc::new(NativeAtomicDocumentIo), + }) + } + + #[cfg(test)] + pub(crate) fn with_io( + root: impl AsRef, + io: std::sync::Arc, + ) -> Result { + fs::create_dir_all(root.as_ref())?; + Ok(Self { + root: fs::canonicalize(root.as_ref())?, + io, }) } @@ -162,17 +174,28 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { } let root = self.managed_root(&marker.resource_id); fs::create_dir(&root)?; - let result = self + let locator = self.marker_locator(&root); + let snapshot = self .io - .write_atomic( - &self.marker_locator(&root), - serde_json::to_vec_pretty(marker)?, + .observe( + &locator, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), ) + .await?; + let result = self + .io + .replace(&locator, snapshot, serde_json::to_vec_pretty(marker)?) .await; - if result.is_err() { + if matches!( + &result, + Err(DocumentWriteFailure { + publication: PublicationState::NotPublished, + .. + }) + ) { let _ = fs::remove_dir_all(&root); } - result?; + result.map_err(|failure| marker_write_error(&marker.resource_id, failure))?; Ok(self.marker_ref(marker, &root)) }) } @@ -190,12 +213,19 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { { return Err(AppError::StaleTarget); } - self.io - .write_atomic( - &self.marker_locator(&root), - serde_json::to_vec_pretty(marker)?, + let locator = self.marker_locator(&root); + let snapshot = self + .io + .observe( + &locator, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), ) + .await?; + self.io + .replace(&locator, snapshot, serde_json::to_vec_pretty(marker)?) .await + .map(|_| ()) + .map_err(|failure| marker_write_error(&marker_ref.resource_id, failure)) }) } @@ -276,6 +306,19 @@ impl RecoveryMarkerStore for NativeRecoveryMarkerStore { } } +fn marker_write_error(resource_id: &RecoveryResourceId, failure: DocumentWriteFailure) -> AppError { + if failure.publication == PublicationState::NotPublished { + return failure.error; + } + AppError::RecoveryRequired { + recovery_resource_id: resource_id.clone(), + message: format!( + "recovery marker publication is not confirmed during {:?}: {}", + failure.phase, failure.error + ), + } +} + #[cfg(test)] mod tests { use std::fs; diff --git a/src-tauri/src/environment/wsl/operations/materialize.rs b/src-tauri/src/environment/wsl/operations/materialize.rs index e96e97cd..e5a23105 100644 --- a/src-tauri/src/environment/wsl/operations/materialize.rs +++ b/src-tauri/src/environment/wsl/operations/materialize.rs @@ -447,6 +447,40 @@ impl PreparedUnitExecutor for WslPreparedUnitExecutor { }) }) .collect::, AppError>>()?; + let write_destinations = entries + .iter() + .filter(|entry| { + !matches!( + entry.action, + environment_protocol::MutationEntryAction::Keep + ) + }) + .map(|entry| entry.destination.clone()) + .collect::>(); + if !write_destinations.is_empty() { + let expected_count = + u32::try_from(write_destinations.len()).map_err(|_| AppError::StaleTarget)?; + let response: environment_protocol::WriteProbeResponse = self + .workspace + .request_worker_payload_for_generation( + self.session.runtime_generation, + environment_protocol::Message::ProbeWriteTargets { + request: environment_protocol::WriteProbeRequest { + destinations: write_destinations, + deadline_millis: 10_000, + }, + }, + 1024, + Some(cancellation), + Duration::from_secs(10), + ) + .await?; + if response.checked_count != expected_count { + return Err(AppError::ConfigurationCorrupted { + message: "invalid WSL write preflight response".to_string(), + }); + } + } Ok(PreparedWslUnit { generation: self.session.runtime_generation, resource_id: resource_id.clone(), @@ -797,7 +831,7 @@ mod windows_worker_mutation_tests { ); run_fixture( &distro, - "set -eu; mkdir -p \"$1/demo\"; printf old > \"$1/demo/SKILL.md\"", + "set -eu; mkdir -p \"$1/demo\"; printf old > \"$1/demo/SKILL.md\"; chmod 500 \"$1\"", &fixture, ) .await; @@ -869,6 +903,19 @@ mod windows_worker_mutation_tests { MutationKind::Remove, ); + let blocked = executor + .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) + .await; + assert!(blocked.is_err()); + assert_ne!( + inspect_entries(&workspace, std::slice::from_ref(&destination), None) + .await + .unwrap()[0] + .kind, + crate::environment::wsl::operations::entry::PosixEntryKind::Missing + ); + run_fixture(&distro, "chmod 700 \"$1\"", &fixture).await; + let prepared = executor .prepare(&unit, &BTreeMap::new(), CancellationSignal::default()) .await diff --git a/src-tauri/src/environment/wsl/worker.rs b/src-tauri/src/environment/wsl/worker.rs index ddeb5896..ba6d18cd 100644 --- a/src-tauri/src/environment/wsl/worker.rs +++ b/src-tauri/src/environment/wsl/worker.rs @@ -19,7 +19,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::process::Child; use tokio::sync::{oneshot, watch}; use tokio::task::AbortHandle; -use tokio::time::{sleep, Duration}; +use tokio::time::Duration; use tokio_util::codec::FramedRead; use crate::core::mutation::CancellationSignal; @@ -156,7 +156,7 @@ enum ExpectedResponse { #[derive(Debug)] pub(super) struct MutationSessionError { pub error: AppError, - pub accepted_resource_id: Option, + pub recovery_resource_id: Option, } enum RoutedResponse { @@ -312,7 +312,8 @@ impl WorkerSession { max_payload_bytes: usize, cancellation: Option, ) -> Result, AppError> { - match self + let write_probe = matches!(&message, Message::ProbeWriteTargets { .. }); + let response = self .request_response_with_payload_limit( message, limit, @@ -320,8 +321,17 @@ impl WorkerSession { cancellation, max_payload_bytes, ) - .await? - { + .await; + let probe_may_be_running = write_probe + && (matches!( + &response, + Err(AppError::WslCommandTimedOut | AppError::EnvironmentUnavailable { .. }) + ) || *self.inner.closed.borrow()); + if probe_may_be_running { + self.retire_and_wait("worker write probe did not reach a terminal result") + .await; + } + match response? { RoutedResponse::Payload(payload) => Ok(payload), RoutedResponse::Control(Message::Error { code, phase, .. }) => Err( payload_control_error(&self.inner.distro_name, &code, &phase), @@ -340,6 +350,7 @@ impl WorkerSession { max_payload_bytes: usize, limit: Duration, ) -> Result { + let deadline = tokio::time::Instant::now() + limit; let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); let (response_tx, response_rx) = oneshot::channel(); self.inner @@ -356,21 +367,33 @@ impl WorkerSession { sender: response_tx, }, ); - if let Err(error) = self - .inner - .writer - .send_transfer_with_limit(request_id, transfer_id, payload, max_payload_bytes) - .await + match tokio::time::timeout_at( + deadline, + self.inner.writer.send_transfer_with_limit( + request_id, + transfer_id, + payload, + max_payload_bytes, + ), + ) + .await { - self.inner - .routes - .lock() - .expect("worker response routes lock poisoned") - .pending - .remove(&request_id); - return Err(unavailable(&self.inner.distro_name, error.to_string())); + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + Err(_) => { + self.retire("worker transfer did not finish before its deadline"); + return Err(AppError::WslCommandTimedOut); + } } - match tokio::time::timeout(limit, response_rx).await { + match tokio::time::timeout_at(deadline, response_rx).await { Ok(Ok(Ok(RoutedResponse::Control(message)))) => Ok(message), Ok(Ok(Ok(RoutedResponse::Payload(_)))) => Err(unavailable( &self.inner.distro_name, @@ -382,32 +405,7 @@ impl WorkerSession { "worker response router stopped", )), Err(_) => { - let cancelled = { - let mut routes = self - .inner - .routes - .lock() - .expect("worker response routes lock poisoned"); - let cancelled = routes.cancel_request(request_id); - if cancelled { - routes.remember_tombstone(request_id); - } - cancelled - }; - if cancelled { - let cancel_request_id = - self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); - let _ = self - .inner - .writer - .send_control(WireRecord::Control(Envelope { - request_id: cancel_request_id, - message: Message::Cancel { - target_request_id: request_id, - }, - })) - .await; - } + self.retire("worker transfer response exceeded its deadline"); Err(AppError::WslCommandTimedOut) } } @@ -416,10 +414,12 @@ impl WorkerSession { pub(super) async fn send_prepared_mutation( &self, transfer_id: u64, + resource_id: &str, payload: &[u8], cancellation: CancellationSignal, limit: Duration, ) -> Result { + let deadline = tokio::time::Instant::now() + limit; let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); let accepted_resource = Arc::new(Mutex::new(None)); let (response_tx, response_rx) = oneshot::channel(); @@ -437,29 +437,38 @@ impl WorkerSession { sender: response_tx, }, ); - if let Err(error) = self - .inner - .writer - .send_transfer_with_limit( + match tokio::time::timeout_at( + deadline, + self.inner.writer.send_transfer_with_limit( request_id, transfer_id, payload, environment_protocol::MAX_MUTATION_TRANSFER_BYTES, - ) - .await + ), + ) + .await { - self.inner - .routes - .lock() - .expect("worker response routes lock poisoned") - .pending - .remove(&request_id); - return Err(MutationSessionError { - error: unavailable(&self.inner.distro_name, error.to_string()), - accepted_resource_id: None, - }); + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(MutationSessionError { + error: unavailable(&self.inner.distro_name, error.to_string()), + recovery_resource_id: None, + }); + } + Err(_) => { + self.retire("worker mutation transfer did not finish before its deadline"); + return Err(MutationSessionError { + error: AppError::WslCommandTimedOut, + recovery_resource_id: None, + }); + } } - let deadline = tokio::time::Instant::now() + limit; let mut response_rx = response_rx; let mut cancel_sent = false; let waited = tokio::select! { @@ -468,16 +477,16 @@ impl WorkerSession { _ = cancellation.cancelled() => { cancel_sent = true; let cancel_request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); - let _ = self - .inner - .writer - .send_control(WireRecord::Control(Envelope { + let _ = tokio::time::timeout_at( + deadline, + self.inner.writer.send_control(WireRecord::Control(Envelope { request_id: cancel_request_id, message: Message::Cancel { target_request_id: request_id, }, - })) - .await; + })), + ) + .await; tokio::time::timeout_at(deadline, &mut response_rx) .await .ok() @@ -506,6 +515,7 @@ impl WorkerSession { "worker response router stopped", )), None => { + self.retire("worker mutation did not reach a terminal result before its deadline"); let cancelled = { let mut routes = self .inner @@ -541,10 +551,11 @@ impl WorkerSession { }; result.map_err(|error| MutationSessionError { error, - accepted_resource_id: accepted_resource + recovery_resource_id: accepted_resource .lock() .expect("worker mutation accepted state lock poisoned") - .clone(), + .clone() + .or_else(|| Some(resource_id.to_string())), }) } @@ -604,6 +615,7 @@ impl WorkerSession { cancellation: Option, max_payload_bytes: usize, ) -> Result { + let deadline = tokio::time::Instant::now() + limit; let request_id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); let (response_tx, response_rx) = oneshot::channel(); self.inner @@ -620,37 +632,64 @@ impl WorkerSession { sender: response_tx, }, ); - if let Err(error) = self - .inner - .writer - .send_control(WireRecord::Control(Envelope { - request_id, - message, - })) - .await - { + match tokio::time::timeout_at( + deadline, self.inner - .routes - .lock() - .expect("worker response routes lock poisoned") - .pending - .remove(&request_id); - return Err(unavailable(&self.inner.distro_name, error.to_string())); + .writer + .send_control(WireRecord::Control(Envelope { + request_id, + message, + })), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + self.inner + .routes + .lock() + .expect("worker response routes lock poisoned") + .pending + .remove(&request_id); + return Err(unavailable(&self.inner.distro_name, error.to_string())); + } + Err(_) => { + self.retire("worker control request did not send before its deadline"); + return Err(AppError::WslCommandTimedOut); + } } enum WaitResult { Response(T), TimedOut, - Cancelled, + Cancelled { terminal_observed: bool }, } + let mut response_rx = response_rx; let waited = if let Some(cancellation) = cancellation { tokio::select! { - response = response_rx => WaitResult::Response(response), - _ = sleep(limit) => WaitResult::TimedOut, - _ = cancellation.cancelled() => WaitResult::Cancelled, + response = &mut response_rx => WaitResult::Response(response), + _ = tokio::time::sleep_until(deadline) => WaitResult::TimedOut, + _ = cancellation.cancelled() => { + let cancel_request_id = + self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let sent = tokio::time::timeout_at( + deadline, + self.inner.writer.send_control(WireRecord::Control(Envelope { + request_id: cancel_request_id, + message: Message::Cancel { + target_request_id: request_id, + }, + })), + ).await; + let terminal_observed = matches!(sent, Ok(Ok(()))) + && tokio::time::timeout_at(deadline, &mut response_rx) + .await + .is_ok(); + WaitResult::Cancelled { terminal_observed } + }, } } else { - match tokio::time::timeout(limit, response_rx).await { + match tokio::time::timeout_at(deadline, response_rx).await { Ok(response) => WaitResult::Response(response), Err(_) => WaitResult::TimedOut, } @@ -661,8 +700,7 @@ impl WorkerSession { &self.inner.distro_name, "worker response router stopped", )), - reason @ (WaitResult::TimedOut | WaitResult::Cancelled) => { - let was_cancelled = matches!(reason, WaitResult::Cancelled); + WaitResult::TimedOut => { let cancelled = { let mut routes = self .inner @@ -681,19 +719,32 @@ impl WorkerSession { let _ = self .inner .writer - .send_control(WireRecord::Control(Envelope { + .try_send_control(WireRecord::Control(Envelope { request_id: cancel_request_id, message: Message::Cancel { target_request_id: request_id, }, - })) - .await; + })); } - if was_cancelled { - Err(AppError::MutationCancelled) - } else { - Err(AppError::WslCommandTimedOut) + Err(AppError::WslCommandTimedOut) + } + WaitResult::Cancelled { terminal_observed } => { + let cancelled = { + let mut routes = self + .inner + .routes + .lock() + .expect("worker response routes lock poisoned"); + let cancelled = routes.cancel_request(request_id); + if cancelled { + routes.remember_tombstone(request_id); + } + cancelled + }; + if cancelled && !terminal_observed { + self.retire("worker cancellation did not reach a terminal result"); } + Err(AppError::MutationCancelled) } } } @@ -702,6 +753,43 @@ impl WorkerSession { self.inner.closed.subscribe() } + fn retire(&self, message: &str) { + fail_pending( + &self.inner.routes, + unavailable(&self.inner.distro_name, message), + ); + self.inner.closed.send_replace(true); + self.inner.reader_task.abort(); + self.inner.writer_task.abort(); + #[cfg(target_os = "windows")] + { + if let Some(task) = &self.inner.stderr_task { + task.abort(); + } + if let Ok(mut child) = self.inner.child.lock() { + if let Some(child) = child.as_mut() { + let _ = child.start_kill(); + } + } + } + } + + async fn retire_and_wait(&self, message: &str) { + self.retire(message); + #[cfg(target_os = "windows")] + { + let child = self + .inner + .child + .lock() + .ok() + .and_then(|mut child| child.take()); + if let Some(mut child) = child { + let _ = child.wait().await; + } + } + } + async fn handshake( &self, expected: &super::WslSession, @@ -1418,6 +1506,48 @@ mod tests { server_task.await.unwrap(); } + #[tokio::test] + async fn control_request_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + tokio::time::timeout(Duration::from_secs(1), async { + for request_id in 1..=65 { + session + .inner + .writer + .send_control(WireRecord::Control(Envelope { + request_id, + message: Message::ObservePath { + path: "/tmp".to_string(), + }, + })) + .await + .unwrap(); + } + }) + .await + .expect("control queue fixture must fill without blocking"); + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.request( + Message::ObservePath { + path: "/home/alice".to_string(), + }, + Duration::from_millis(25), + ), + ) + .await + .expect("the request deadline must include control queue backpressure"); + + assert_eq!( + result.unwrap_err(), + crate::error::AppError::WslCommandTimedOut + ); + assert!(*session.closed_receiver().borrow()); + } + #[tokio::test] async fn payload_response_is_bounded_reassembled_and_verified() { let (client, server) = duplex(4096); @@ -1532,6 +1662,27 @@ mod tests { server_task.await.unwrap(); } + #[tokio::test] + async fn prepared_transfer_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; environment_protocol::MAX_PAYLOAD_CHUNK_BYTES * 16]; + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.send_prepared_transfer(700, &payload, payload.len(), Duration::from_millis(25)), + ) + .await + .expect("the transfer deadline must include writer backpressure"); + + assert_eq!( + result.unwrap_err(), + crate::error::AppError::WslCommandTimedOut + ); + assert!(*session.closed_receiver().borrow()); + } + #[tokio::test] async fn mutation_acceptance_keeps_the_pending_route_until_terminal_payload() { let (client, server) = duplex(4096); @@ -1573,6 +1724,7 @@ mod tests { let outcome = session .send_prepared_mutation( 700, + "resource-1", b"request", CancellationSignal::default(), Duration::from_secs(1), @@ -1588,6 +1740,32 @@ mod tests { server_task.await.unwrap(); } + #[tokio::test] + async fn mutation_deadline_includes_a_blocked_send() { + let (client, _server) = duplex(1); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let payload = vec![0x5a; environment_protocol::MAX_PAYLOAD_CHUNK_BYTES * 16]; + + let result = tokio::time::timeout( + Duration::from_millis(500), + session.send_prepared_mutation( + 700, + "resource-requested", + &payload, + CancellationSignal::default(), + Duration::from_millis(25), + ), + ) + .await + .expect("the mutation deadline must include writer backpressure") + .unwrap_err(); + + assert_eq!(result.error, crate::error::AppError::WslCommandTimedOut); + assert_eq!(result.recovery_resource_id, None); + assert!(*session.closed_receiver().borrow()); + } + #[tokio::test] async fn mutation_disconnect_reports_whether_the_worker_accepted_it() { for accepted in [false, true] { @@ -1621,6 +1799,7 @@ mod tests { let error = session .send_prepared_mutation( 710, + "resource-requested", b"request", CancellationSignal::default(), Duration::from_secs(1), @@ -1629,8 +1808,12 @@ mod tests { .unwrap_err(); assert_eq!( - error.accepted_resource_id.as_deref(), - accepted.then_some("resource-accepted") + error.recovery_resource_id.as_deref(), + Some(if accepted { + "resource-accepted" + } else { + "resource-requested" + }) ); server_task.await.unwrap(); } @@ -1684,7 +1867,13 @@ mod tests { }; let outcome = session - .send_prepared_mutation(711, b"request", cancellation, Duration::from_secs(1)) + .send_prepared_mutation( + 711, + "resource-cancelled", + b"request", + cancellation, + Duration::from_secs(1), + ) .await .map_err(|error| error.error) .unwrap(); @@ -1851,6 +2040,43 @@ mod tests { server_task.await.unwrap(); } + #[tokio::test] + async fn write_probe_timeout_retires_the_worker_session() { + let (client, server) = duplex(4096); + let (client_reader, client_writer) = split(client); + let session = WorkerSession::from_io(client_reader, client_writer, "Ubuntu".to_string()); + let closed = session.closed_receiver(); + let (server_reader, _server_writer) = split(server); + let mut reader = FramedRead::new(server_reader, codec()); + let requesting = session.clone(); + let request = tokio::spawn(async move { + requesting + .request_payload_with_limit( + Message::ProbeWriteTargets { + request: environment_protocol::WriteProbeRequest { + destinations: vec!["/tmp/skills/demo".to_string()], + deadline_millis: 1_000, + }, + }, + Duration::from_millis(50), + 1024, + None, + ) + .await + }); + assert!(matches!( + next_envelope(&mut reader).await.message, + Message::ProbeWriteTargets { .. } + )); + let result = request.await.unwrap(); + + assert_eq!(result, Err(crate::error::AppError::WslCommandTimedOut)); + assert!( + *closed.borrow(), + "a timed-out write probe must not leave a reusable Worker session" + ); + } + #[tokio::test] async fn timed_out_request_is_cancelled_and_its_late_response_is_ignored() { let (client, server) = duplex(4096); diff --git a/src-tauri/src/runtime/plan_runner.rs b/src-tauri/src/runtime/plan_runner.rs index 6ace1ee5..3e6c425c 100644 --- a/src-tauri/src/runtime/plan_runner.rs +++ b/src-tauri/src/runtime/plan_runner.rs @@ -22,27 +22,49 @@ use crate::environment::wsl::operations::materialize::WslPreparedUnitExecutor; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::runtime::recovery::{RuntimeRecoveryGraph, RuntimeRecoveryService}; +use crate::storage::atomic_document::{AtomicDocumentIo, DocumentWriteFailure}; use crate::storage::lock_plan::{LockCommitReceipt, LockPlanCommitter, PreparedLockMutation}; -#[derive(Default)] -pub struct RuntimeLockCommitter; +pub struct RuntimeLockCommitter { + io: Arc, +} + +impl Default for RuntimeLockCommitter { + fn default() -> Self { + Self::new() + } +} impl RuntimeLockCommitter { pub fn new() -> Self { - Self + Self { + io: Arc::new(NativeAtomicDocumentIo), + } + } +} + +impl RuntimeLockCommitter { + #[cfg(test)] + pub(crate) fn with_io(io: Arc) -> Self { + Self { io } } } -impl PreparedLockCommitter for RuntimeLockCommitter { +impl PreparedLockCommitter for RuntimeLockCommitter +where + I: AtomicDocumentIo, +{ fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { if mutation.target.environment != EnvironmentRef::Native { - return Err(AppError::StaleEnvironment); + return Err(DocumentWriteFailure::not_published( + AppError::StaleEnvironment, + )); } - LockPlanCommitter::new(Arc::new(NativeAtomicDocumentIo)) + LockPlanCommitter::new(Arc::clone(&self.io)) .commit(mutation.clone()) .await }) @@ -245,7 +267,7 @@ impl PreparedLockCommitter for SharedLocks { fn commit<'a>( &'a self, mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { self.0.commit(mutation) } } @@ -406,7 +428,7 @@ mod tests { fn commit<'a>( &'a self, _mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async { panic!("test plan has no lock mutation") }) } } diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index efeeef31..4932f9b8 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -749,22 +749,34 @@ fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result Ok(catalog), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(catalog), - Err(error) => { - crate::environment::native::atomic_file::write_native_atomic( - &root.join("catalog.json"), - &original_bytes, - )?; - Err(error.into()) + let metadata = match fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Some(metadata), + Ok(_) => return Err(AppError::StaleTarget), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if metadata.is_none() { + save_native_catalog_if_unchanged(root, Some(&original_bytes), &updated_bytes)?; + return Ok(catalog); + } + let catalog_hash = bytes_sha256(&updated_bytes); + let commit = (|| { + stage_native_skill_deletion(root, &destination)?; + prepare_native_catalog_commit(root, &catalog_hash)?; + save_native_catalog_if_unchanged(root, Some(&original_bytes), &updated_bytes)?; + finalize_native_catalog_commit(root, &catalog_hash) + })(); + if let Err(error) = commit { + let current_hash = fs::read(root.join("catalog.json")) + .ok() + .map(|bytes| bytes_sha256(&bytes)); + recover_native_library_transactions(root, current_hash.as_deref())?; + if current_hash.as_deref() != Some(&catalog_hash) { + return Err(error); } } + Ok(catalog) } async fn delete_wsl_library( @@ -1315,6 +1327,18 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul let destination = PathBuf::from(record.destination); let stage = transaction.join("stage"); let backup = transaction.join("backup"); + if matches!( + record.phase, + NativeLibraryTransactionPhase::CatalogCommitted + ) { + if let Err(error) = cleanup_native_committed_transaction(&transaction) { + log::warn!( + "Skill Library committed transaction cleanup remains pending at {}: {error}", + transaction.display() + ); + } + continue; + } match record.phase { NativeLibraryTransactionPhase::Preparing => {} NativeLibraryTransactionPhase::Staged if destination.exists() || stage.exists() => {} @@ -1325,6 +1349,14 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul { fs::rename(&backup, &destination)?; } + NativeLibraryTransactionPhase::BackedUp + if !record.desired_presence + && !destination.exists() + && backup.exists() + && !stage.exists() => + { + fs::rename(&backup, &destination)?; + } NativeLibraryTransactionPhase::BackedUp if destination.exists() && backup.exists() && !stage.exists() => {} NativeLibraryTransactionPhase::Activated => { @@ -1342,8 +1374,6 @@ fn recover_native_transactions(root: &Path, catalog_hash: Option<&str>) -> Resul rollback_native_library_content(&destination, &backup)?; } } - NativeLibraryTransactionPhase::CatalogCommitted - if destination.exists() == record.desired_presence => {} _ => { return Err(AppError::ConfigurationCorrupted { message: format!( @@ -1412,16 +1442,30 @@ fn finalize_native_catalog_commit(root: &Path, catalog_hash: &str) -> Result<(), record.desired_presence, record.expected_catalog_hash, )?; - let backup = transaction.join("backup"); - if backup.exists() { - fs::remove_dir_all(backup)?; + if let Err(error) = cleanup_native_committed_transaction(&transaction) { + log::warn!( + "Skill Library commit succeeded but cleanup remains pending at {}: {error}", + transaction.display() + ); } - fs::remove_dir_all(transaction)?; } } Ok(()) } +fn cleanup_native_committed_transaction(transaction: &Path) -> Result<(), AppError> { + let stage = transaction.join("stage"); + if stage.exists() { + fs::remove_dir_all(stage)?; + } + let backup = transaction.join("backup"); + if backup.exists() { + fs::remove_dir_all(backup)?; + } + fs::remove_dir_all(transaction)?; + Ok(()) +} + fn native_transaction_directories(root: &Path) -> Result, AppError> { let transactions = root.join(".transactions"); let Ok(entries) = fs::read_dir(transactions) else { @@ -1487,6 +1531,7 @@ mod tests { LibraryApplicationRecord, LibraryApplicationRepository, LibraryApplicationState, }; use crate::application::skill_libraries::SkillLibraryModule; + use crate::application::skill_libraries::LIBRARY_SCHEMA_VERSION; use crate::application::skill_paths::{SkillPathObserver, SkillTargetRequest}; use crate::core::projects::ProjectMigrationState; use crate::core::skill_payload::build_skill_payload; @@ -1685,6 +1730,157 @@ mod tests { assert!(library_path.is_file()); } + #[cfg(unix)] + #[tokio::test] + async fn native_library_delete_stays_committed_when_backup_cleanup_is_blocked() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = Arc::new(RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + )); + let created = SkillLibraryModule::new(repository.clone()) + .create(EnvironmentRef::Native, "Backend".to_string()) + .await + .unwrap(); + let library_id = created.libraries[0].id.clone(); + let library_path = root.join("libraries").join(library_id.as_str()); + let locked = library_path.join("skills/locked"); + fs::create_dir_all(&locked).unwrap(); + fs::write(locked.join("SKILL.md"), b"content").unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o500)).unwrap(); + + let result = repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await; + + assert!(result.is_ok(), "delete must stay committed: {result:?}"); + assert!(repository + .load(&EnvironmentRef::Native) + .await + .expect("load committed catalog") + .libraries + .is_empty()); + assert!(!library_path.exists()); + + if let Ok(entries) = fs::read_dir(root.join(".transactions")) { + for entry in entries.flatten() { + let locked = entry.path().join("backup/skills/locked"); + if locked.exists() { + fs::set_permissions(locked, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + } + } + + #[tokio::test] + async fn committed_cleanup_does_not_depend_on_a_later_destination_state() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let transaction = root.join(".transactions/committed-cleanup"); + let destination = root.join("libraries/lib-one/skills/demo"); + fs::create_dir_all(transaction.join("backup")).unwrap(); + fs::write(transaction.join("backup/SKILL.md"), b"old").unwrap(); + fs::write( + transaction.join("transaction.json"), + serde_json::to_vec(&NativeLibraryTransaction { + destination: destination.to_string_lossy().into_owned(), + phase: NativeLibraryTransactionPhase::CatalogCommitted, + desired_presence: true, + expected_catalog_hash: Some("committed".to_string()), + }) + .unwrap(), + ) + .unwrap(); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: Vec::new(), + extra: serde_json::Map::new(), + }; + fs::write( + root.join("catalog.json"), + serde_json::to_vec_pretty(&catalog).unwrap(), + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let loaded = repository.load(&EnvironmentRef::Native).await; + + assert!(loaded.is_ok()); + assert!(!transaction.exists()); + } + + #[cfg(windows)] + #[tokio::test] + async fn native_library_delete_keeps_the_original_while_a_member_file_is_open() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING, + }; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = Arc::new(RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + )); + let created = SkillLibraryModule::new(repository.clone()) + .create(EnvironmentRef::Native, "Backend".to_string()) + .await + .unwrap(); + let library_id = created.libraries[0].id.clone(); + let library_path = root.join("libraries").join(library_id.as_str()); + let member_file = library_path.join("skills/locked/SKILL.md"); + fs::create_dir_all(member_file.parent().unwrap()).unwrap(); + fs::write(&member_file, b"content").unwrap(); + let wide = member_file + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE); + + let result = repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await; + + assert!(result.is_err(), "open member must block the directory move"); + let catalog = repository + .load(&EnvironmentRef::Native) + .await + .expect("load original catalog"); + assert_eq!(catalog.libraries.len(), 1); + assert_eq!(catalog.libraries[0].id, library_id); + assert!(member_file.exists()); + + unsafe { CloseHandle(handle) }; + repository + .delete_library(&EnvironmentRef::Native, &library_id) + .await + .expect("delete after releasing the file"); + assert!(!library_path.exists()); + } + #[tokio::test] async fn native_repository_rejects_deleting_an_unknown_library() { let temp = tempfile::tempdir().unwrap(); @@ -2065,4 +2261,41 @@ mod tests { serde_json::from_slice(&fs::read(applications.join("global.json")).unwrap()).unwrap(); assert!(stored.get("target").is_none()); } + + #[tokio::test] + async fn native_repository_restores_a_retired_member_if_delete_crashes_after_rename() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + repository + .save(&EnvironmentRef::Native, &LibraryCatalog::default()) + .await + .unwrap(); + let destination = root.join("libraries/library-1/skills/demo"); + let transaction = root.join(".transactions/interrupted-delete"); + let backup = transaction.join("backup"); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + fs::create_dir_all(&backup).unwrap(); + fs::write(backup.join("SKILL.md"), b"retired content").unwrap(); + write_native_transaction( + &transaction.join("transaction.json"), + &destination, + NativeLibraryTransactionPhase::BackedUp, + false, + None, + ) + .unwrap(); + + repository.load(&EnvironmentRef::Native).await.unwrap(); + + assert_eq!( + fs::read(destination.join("SKILL.md")).unwrap(), + b"retired content" + ); + assert!(!transaction.exists()); + } } diff --git a/src-tauri/src/test_support/native_workflow.rs b/src-tauri/src/test_support/native_workflow.rs index c4aa2321..2c2be4c0 100644 --- a/src-tauri/src/test_support/native_workflow.rs +++ b/src-tauri/src/test_support/native_workflow.rs @@ -31,7 +31,7 @@ use crate::application::manage_agents::{ ManageAgentsPreviewRequest, ManageAgentsRequest, ManageAgentsService, }; use crate::application::mutation::coordinator::{ - BoxFuture, MutationCoordinator, PreparedEntryExecutor, PreparedLockCommitter, + BoxFuture, MutationCoordinator, PreparedEntryTestDriver, PreparedLockCommitter, RuntimeRevisionSource, }; use crate::application::mutation::executor::MutationPlanExecutor; @@ -85,6 +85,7 @@ use crate::models::InstallMode; use crate::runtime::copy_service::RuntimeCopyProjectComparator; use crate::runtime::plan_runner::{RuntimeExecutionDependencies, RuntimePlanExecutor}; use crate::runtime::planning_facts::{NativeRuntimeSnapshot, RuntimePlanningFactSource}; +use crate::storage::atomic_document::DocumentWriteFailure; use crate::storage::lock_plan::{LockCommitReceipt, PreparedLockMutation}; pub(crate) struct StaticRegistry(pub(crate) Arc); @@ -217,7 +218,7 @@ struct SelectiveVerifyStaged { fail_verify: bool, } -impl PreparedEntryExecutor for SelectiveVerifyFailureEntryExecutor { +impl PreparedEntryTestDriver for SelectiveVerifyFailureEntryExecutor { type Staged = SelectiveVerifyStaged; fn stage<'a>( @@ -269,7 +270,7 @@ impl PreparedEntryExecutor for SelectiveVerifyFailureEntryExecutor { } } -impl PreparedEntryExecutor for VerifyFailureEntryExecutor { +impl PreparedEntryTestDriver for VerifyFailureEntryExecutor { type Staged = NativePreparedEntrySet; fn stage<'a>( @@ -338,13 +339,15 @@ impl PreparedLockCommitter for RejectingLockCommitter { fn commit<'a>( &'a self, _mutation: &'a PreparedLockMutation, - ) -> BoxFuture<'a, Result> { + ) -> BoxFuture<'a, Result> { self.attempted .store(true, std::sync::atomic::Ordering::SeqCst); Box::pin(async { - Err(AppError::ExecutionFailed { - message: "injected Manage Agents lock failure".to_string(), - }) + Err(DocumentWriteFailure::not_published( + AppError::ExecutionFailed { + message: "injected Manage Agents lock failure".to_string(), + }, + )) }) } } @@ -2131,7 +2134,7 @@ mod update_lifecycle { use super::*; use crate::application::install::InstallFuture; use crate::application::mutation::coordinator::{ - BoxFuture, MutationCoordinator, PreparedEntryExecutor, + BoxFuture, MutationCoordinator, PreparedEntryTestDriver, }; use crate::application::mutation::executor::MutationPlanExecutor; use crate::application::mutation::plan::{ExecutionUnit, MutationPlan}; @@ -2190,7 +2193,7 @@ mod update_lifecycle { private_root: PathBuf, } - impl PreparedEntryExecutor for StageFailureEntryExecutor { + impl PreparedEntryTestDriver for StageFailureEntryExecutor { type Staged = NativePreparedEntrySet; fn stage<'a>( From 718c2a52326881860345b446947e4be6cf94d425 Mon Sep 17 00:00:00 2001 From: Hccake Date: Mon, 7 Sep 2026 23:36:14 +0800 Subject: [PATCH 09/27] refactor(libraries): model durable scope reconciliation Track application selection, completed membership and pending reconciliation separately. Enumerate persisted application records, expose incomplete inventories and derive recovery attention from durable resources. Limit membership retries to affected Skills, retain confirmed reapply intent and saved Agent associations, and validate the final storage contracts. --- .../crates/environment-protocol/src/lib.rs | 10 + .../wsl-environment-worker/src/library.rs | 109 +- .../crates/wsl-environment-worker/src/main.rs | 35 + .../wsl-environment-worker/tests/stdio.rs | 67 + src-tauri/src/application/agent_selection.rs | 1 + .../src/application/environment_projects.rs | 5 + .../application/library_agent_placements.rs | 79 +- .../src/application/library_application.rs | 2601 +++++++++++++++-- .../src/application/library_candidates.rs | 301 +- src-tauri/src/application/recovery.rs | 63 + .../application/scope_skill_planning/mod.rs | 122 +- src-tauri/src/application/skill_read.rs | 2 + .../src/environment/wsl/operations/library.rs | 26 + src-tauri/src/runtime/mod.rs | 3 +- src-tauri/src/runtime/skill_libraries.rs | 697 ++++- src-tauri/src/test_support/native_workflow.rs | 82 +- src/bindings.ts | 5 +- 17 files changed, 3784 insertions(+), 424 deletions(-) diff --git a/src-tauri/crates/environment-protocol/src/lib.rs b/src-tauri/crates/environment-protocol/src/lib.rs index 5165181c..dcbec2d9 100644 --- a/src-tauri/crates/environment-protocol/src/lib.rs +++ b/src-tauri/crates/environment-protocol/src/lib.rs @@ -227,6 +227,13 @@ pub struct LibraryCatalogResponse { pub revision: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryApplicationIndex { + pub project_ids: Vec, + pub problem_keys: Vec, + pub complete: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LibraryOperationPreparation { pub total_bytes: u64, @@ -726,6 +733,9 @@ pub enum Message { ReadLibraryCatalog { deadline_millis: u64, }, + ListLibraryApplications { + deadline_millis: u64, + }, PrepareLibraryOperation { request: LibraryOperationPreparation, }, diff --git a/src-tauri/crates/wsl-environment-worker/src/library.rs b/src-tauri/crates/wsl-environment-worker/src/library.rs index be2df2d6..070d5494 100644 --- a/src-tauri/crates/wsl-environment-worker/src/library.rs +++ b/src-tauri/crates/wsl-environment-worker/src/library.rs @@ -6,8 +6,8 @@ use environment_engine::library::{ }; use environment_engine::linux_mutation::ParentIdentity; use environment_protocol::{ - LibraryCatalogResponse, LibraryMemberAction, LibraryOperationAction, LibraryOperationRequest, - MAX_REQUEST_DEADLINE_MILLIS, + LibraryApplicationIndex, LibraryCatalogResponse, LibraryMemberAction, LibraryOperationAction, + LibraryOperationRequest, MAX_DIRECTORY_COUNT_LIMIT, MAX_REQUEST_DEADLINE_MILLIS, }; use crate::payload::{PayloadError, PayloadManager}; @@ -49,6 +49,90 @@ impl LibraryManager { }) } + pub fn list_applications(&self) -> Result { + let applications = self.root.join("applications"); + let mut project_ids = Vec::new(); + let mut problem_keys = Vec::new(); + match std::fs::read_dir(&applications) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + problem_keys.push("applications/".to_string()); + continue; + } + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name != "global.json" && name != "projects" { + problem_keys.push(name); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LibraryApplicationIndex { + project_ids, + problem_keys, + complete: true, + }); + } + Err(_) => { + problem_keys.push("applications".to_string()); + return Ok(LibraryApplicationIndex { + project_ids, + problem_keys, + complete: false, + }); + } + } + let projects = applications.join("projects"); + match std::fs::read_dir(projects) { + Ok(entries) => { + for (index, entry) in entries.enumerate() { + if index == MAX_DIRECTORY_COUNT_LIMIT as usize { + problem_keys.push("projects/".to_string()); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + problem_keys.push("projects/".to_string()); + continue; + } + }; + let name = entry.file_name(); + let display = name.to_string_lossy().into_owned(); + let project_id = std::path::Path::new(&name) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|_| { + std::path::Path::new(&name) + .extension() + .is_some_and(|value| value == "json") + }); + let valid = entry.file_type().is_ok_and(|kind| kind.is_file()) + && project_id.is_some_and(valid_component); + if let Some(project_id) = project_id.filter(|_| valid) { + project_ids.push(project_id.to_string()); + } else { + problem_keys.push(format!("projects/{display}")); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => problem_keys.push("projects".to_string()), + } + project_ids.sort(); + project_ids.dedup(); + problem_keys.sort(); + problem_keys.dedup(); + Ok(LibraryApplicationIndex { + complete: problem_keys.is_empty(), + project_ids, + problem_keys, + }) + } + pub fn execute( &self, request: LibraryOperationRequest, @@ -181,3 +265,24 @@ fn map_engine_error(error: engine::LibraryError) -> LibraryError { fn map_payload_error(_error: PayloadError) -> LibraryError { LibraryError::StalePayload } + +#[cfg(test)] +mod tests { + use super::LibraryManager; + + #[test] + fn inaccessible_project_namespace_returns_an_incomplete_inventory() { + let home = tempfile::tempdir().unwrap(); + let applications = home.path().join(".skill-deck/skill-libraries/applications"); + std::fs::create_dir_all(&applications).unwrap(); + std::fs::write(applications.join("projects"), b"not a directory").unwrap(); + + let inventory = LibraryManager::new(home.path().to_path_buf()) + .list_applications() + .unwrap(); + + assert!(inventory.project_ids.is_empty()); + assert_eq!(inventory.problem_keys, vec!["projects"]); + assert!(!inventory.complete); + } +} diff --git a/src-tauri/crates/wsl-environment-worker/src/main.rs b/src-tauri/crates/wsl-environment-worker/src/main.rs index ffc5b3c7..26572667 100644 --- a/src-tauri/crates/wsl-environment-worker/src/main.rs +++ b/src-tauri/crates/wsl-environment-worker/src/main.rs @@ -211,6 +211,7 @@ async fn main() -> Result<(), Box> { | Message::CountDirectoryEntries { .. } | Message::ReadDocuments { .. } | Message::ReadLibraryCatalog { .. } + | Message::ListLibraryApplications { .. } | Message::ListChildDirectories { .. } | Message::MapPathsToWindows { .. } | Message::MapHostPaths { .. } @@ -1281,6 +1282,40 @@ async fn execute_business_request( Err(error) => send_library_error(&writer, request_id, error, "libraryRead").await?, } } + Message::ListLibraryApplications { deadline_millis } => { + let result = if deadline_millis == 0 + || deadline_millis > environment_protocol::MAX_REQUEST_DEADLINE_MILLIS + { + Err(LibraryError::InvalidRequest) + } else { + let task = tokio::task::spawn_blocking(move || { + libraries.blocking_lock().list_applications() + }); + match tokio::time::timeout(Duration::from_millis(deadline_millis), task).await { + Ok(joined) => joined.map_err(|error| error.to_string())?, + Err(_) => { + request.cancelled.store(true, Ordering::Release); + send_error(&writer, request_id, "deadlineExceeded", "applicationList") + .await + .map_err(|error| error.to_string())?; + return Ok(request_id); + } + } + }; + match result { + Ok(response) if !request.cancelled.load(Ordering::Acquire) => { + let payload = encode_payload(&response).map_err(|error| error.to_string())?; + writer + .send_transfer(request_id, request_id, &payload) + .await + .map_err(|error| error.to_string())?; + } + Ok(_) => {} + Err(error) => { + send_library_error(&writer, request_id, error, "applicationList").await? + } + } + } Message::RemovePayload { session_id, payload_name, diff --git a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs index f252a4bf..bfd591b5 100644 --- a/src-tauri/crates/wsl-environment-worker/tests/stdio.rs +++ b/src-tauri/crates/wsl-environment-worker/tests/stdio.rs @@ -1257,6 +1257,73 @@ async fn worker_executes_one_library_catalog_operation_over_stdio() { .success()); } +#[tokio::test] +async fn worker_lists_only_managed_library_application_keys() { + let binary = env!("CARGO_BIN_EXE_wsl-environment-worker"); + let build_id = file_sha256(std::path::Path::new(binary)).unwrap(); + let home = tempfile::tempdir().unwrap(); + let applications = home.path().join(".skill-deck/skill-libraries/applications"); + let projects = applications.join("projects"); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::write(projects.join("registered.json"), b"{}").unwrap(); + std::fs::write(projects.join("orphan.json"), b"{}").unwrap(); + std::fs::create_dir(projects.join("not-a-record.json")).unwrap(); + std::fs::write(applications.join("unexpected.json"), b"{}").unwrap(); + let mut child = Command::new(binary) + .env("WSL_DISTRO_NAME", "Ubuntu") + .env("USER", "alice") + .env("HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let (writer, writer_task) = environment_protocol::spawn_writer(stdin); + let mut reader = FramedRead::new(stdout, codec()); + writer + .send_control(WireRecord::Control(Envelope { + request_id: 1, + message: Message::Handshake { build_id }, + })) + .await + .unwrap(); + let _ = next_message(&mut reader).await; + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 2, + message: Message::ListLibraryApplications { + deadline_millis: 30_000, + }, + })) + .await + .unwrap(); + let response: environment_protocol::LibraryApplicationIndex = + environment_protocol::decode_payload(&next_transfer(&mut reader, 2).await).unwrap(); + + assert_eq!(response.project_ids, vec!["orphan", "registered"]); + assert!(!response.complete); + assert_eq!(response.problem_keys.len(), 2); + + writer + .send_control(WireRecord::Control(Envelope { + request_id: 3, + message: Message::Shutdown, + })) + .await + .unwrap(); + drop(writer); + writer_task.await.unwrap().unwrap(); + assert!(timeout(Duration::from_secs(2), child.wait()) + .await + .unwrap() + .unwrap() + .success()); +} + #[cfg(target_os = "linux")] async fn next_transfer( reader: &mut FramedRead, diff --git a/src-tauri/src/application/agent_selection.rs b/src-tauri/src/application/agent_selection.rs index 8e1e8e67..86f05f1b 100644 --- a/src-tauri/src/application/agent_selection.rs +++ b/src-tauri/src/application/agent_selection.rs @@ -137,6 +137,7 @@ pub struct AgentSelectionGroup { #[specta(rename_all = "camelCase")] pub enum UnavailableAgentSelectionReason { DefinitionMissing, + OptionUnavailable, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/src-tauri/src/application/environment_projects.rs b/src-tauri/src/application/environment_projects.rs index 0b4c85dc..1a29ea4a 100644 --- a/src-tauri/src/application/environment_projects.rs +++ b/src-tauri/src/application/environment_projects.rs @@ -309,6 +309,11 @@ mod tests { .into_iter() .collect(), pending, + sync_state: if pending { + crate::application::library_application::LibraryApplicationSyncState::Pending + } else { + crate::application::library_application::LibraryApplicationSyncState::Synced + }, } } diff --git a/src-tauri/src/application/library_agent_placements.rs b/src-tauri/src/application/library_agent_placements.rs index e7d1628c..6c51d394 100644 --- a/src-tauri/src/application/library_agent_placements.rs +++ b/src-tauri/src/application/library_agent_placements.rs @@ -28,6 +28,7 @@ impl LibraryAgentPlacement { pub(crate) struct LibraryAgentPlacementMap { selection: AgentSelectionSnapshot, placements: BTreeMap, + saved_placements: BTreeMap>, } impl LibraryAgentPlacementMap { @@ -41,15 +42,23 @@ impl LibraryAgentPlacementMap { .collect::>(); let mut projected_options = Vec::new(); let mut placements = BTreeMap::new(); + let mut saved_placements = BTreeMap::new(); for option in catalog.options() { if option.public.kind != AgentInstallOptionKind::StandardDirectory - || option.public.mode_constraint != AgentSelectionModeConstraint::UserSelectable - || !option.public.selectable || option.placement.storage_access != StorageAccess::Native || option.placement.content != DirectoryContentKind::Original { continue; } + if !option.public.agent_ids.is_empty() { + saved_placements + .insert(option.placement.id.clone(), option.public.agent_ids.clone()); + } + if option.public.mode_constraint != AgentSelectionModeConstraint::UserSelectable + || !option.public.selectable + { + continue; + } let selection_agent_ids = option .public .agent_ids @@ -86,6 +95,7 @@ impl LibraryAgentPlacementMap { Self { selection, placements, + saved_placements, } } @@ -132,6 +142,54 @@ impl LibraryAgentPlacementMap { } Ok(result) } + + pub(crate) fn placements_for_saved( + &self, + selected_agent_ids: &[AgentId], + ) -> BTreeSet { + let selected = selected_agent_ids.iter().collect::>(); + self.saved_placements + .iter() + .filter(|(_, agent_ids)| agent_ids.iter().any(|agent_id| selected.contains(agent_id))) + .map(|(id, _)| id.clone()) + .collect() + } + + pub(crate) fn validate_selection_with_persisted( + &self, + requested_agent_ids: &[AgentId], + persisted_agent_ids: &BTreeSet, + ) -> Result<(), LibraryAgentPlacementError> { + let requested = requested_agent_ids.iter().collect::>(); + let available = self + .placements + .values() + .flat_map(|placement| placement.selection_agent_ids.iter()) + .collect::>(); + if let Some(agent_id) = requested.iter().find(|agent_id| { + !available.contains(**agent_id) && !persisted_agent_ids.contains(**agent_id) + }) { + return Err(LibraryAgentPlacementError::UnknownAgent( + (*agent_id).clone(), + )); + } + for (id, placement) in &self.placements { + let selected = placement + .selection_agent_ids + .iter() + .filter(|agent_id| requested.contains(agent_id)) + .collect::>(); + if !selected.is_empty() + && selected.len() != placement.selection_agent_ids.len() + && selected + .iter() + .any(|agent_id| !persisted_agent_ids.contains(*agent_id)) + { + return Err(LibraryAgentPlacementError::PartialSelection(id.clone())); + } + } + Ok(()) + } } #[cfg(test)] @@ -282,5 +340,22 @@ mod tests { ]) .unwrap() .contains(&placement_id)); + assert!(map + .placements_for_saved(&[AgentId::parse("both").unwrap()]) + .contains(&placement_id)); + let persisted = BTreeSet::from([AgentId::parse("private-one").unwrap()]); + assert!(map + .validate_selection_with_persisted( + &[AgentId::parse("private-one").unwrap()], + &persisted, + ) + .is_ok()); + assert!(matches!( + map.validate_selection_with_persisted( + &[AgentId::parse("private-two").unwrap()], + &BTreeSet::new(), + ), + Err(LibraryAgentPlacementError::PartialSelection(id)) if id == placement_id + )); } } diff --git a/src-tauri/src/application/library_application.rs b/src-tauri/src/application/library_application.rs index 0d36bd5c..bb30976a 100644 --- a/src-tauri/src/application/library_application.rs +++ b/src-tauri/src/application/library_application.rs @@ -8,7 +8,8 @@ use specta::Type; use crate::application::agent_selection::{ build_agent_selection_catalog, AgentSelectionAgentKind, AgentSelectionCatalog, - AgentSelectionSnapshot, DirectoryPlacementId, + AgentSelectionSnapshot, DirectoryPlacementId, UnavailableAgentSelection, + UnavailableAgentSelectionReason, }; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_agent_placements::LibraryAgentPlacementMap; @@ -17,7 +18,7 @@ use crate::application::library_candidates::{ ResolvedLibraryCandidateIndex, }; use crate::application::mutation::executor::MutationPlanExecutor; -use crate::application::mutation::plan::PreviewToken; +use crate::application::mutation::plan::{PreparedEntryAction, PreviewToken}; use crate::application::mutation::planning::{ assemble_plan, issue_preview_token, validate_exact_preview, MutationPlanDraft, MutationUnitDraft, PreviewTokenDraft, @@ -43,6 +44,7 @@ use crate::environment::planning::TargetFactResolver; use crate::environment::runtime::{EntryFingerprint, PhysicalTargetKey}; use crate::environment::types::{ResourceLocator, SkillLocationRef, StorageAccess}; use crate::error::AppError; +use crate::storage::atomic_document::DocumentSnapshot; pub const LIBRARY_APPLICATION_SCHEMA_VERSION: u32 = 1; pub type LibraryApplicationFuture<'a, T> = Pin + Send + 'a>>; @@ -55,13 +57,105 @@ pub struct LibraryApplicationState { pub selected_agent_ids: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryMemberIdentity { + pub(crate) library_id: LibraryId, + pub(crate) member_name: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconciliationCheckpoint { + pub members: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReconciliationReason { + ApplicationChanged, + MembershipChanged, + ReapplyRequested, + VerificationRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReconciliationAttention { + Pending, + Unverified, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct PendingLibraryApplication { - pub operation_id: String, +pub struct PendingReconciliation { + pub reconciliation_id: String, + pub attention: ReconciliationAttention, + pub reasons: Vec, pub before_application: LibraryApplicationState, pub target_application: LibraryApplicationState, - pub preview_fingerprint: String, + pub recognized_members: Vec, + pub affected_members: Vec, + pub target_members: Vec, +} + +impl PendingReconciliation { + fn merged_with( + mut self, + reasons: Vec, + target_application: LibraryApplicationState, + recognized_members: Vec, + target_members: Vec, + ) -> Self { + self.affected_members = self + .affected_members + .into_iter() + .chain(changed_member_identities( + &self.target_members, + &target_members, + )) + .collect::>() + .into_iter() + .collect(); + self.reasons = self + .reasons + .into_iter() + .chain(reasons) + .collect::>() + .into_iter() + .collect(); + self.recognized_members = self + .recognized_members + .into_iter() + .chain(recognized_members) + .chain(target_members.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + self.target_application = target_application; + self.target_members = target_members; + self + } +} + +fn reconciliation_members( + checkpoint: &ReconciliationCheckpoint, + pending: Option<&PendingReconciliation>, + desired: &[LibraryMemberIdentity], +) -> Vec { + checkpoint + .members + .iter() + .chain( + pending + .into_iter() + .flat_map(|pending| pending.recognized_members.iter()), + ) + .chain(desired) + .cloned() + .collect::>() + .into_iter() + .collect() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -69,7 +163,8 @@ pub struct PendingLibraryApplication { pub struct LibraryApplicationRecord { pub schema_version: u32, pub current: LibraryApplicationState, - pub pending_operation: Option, + pub checkpoint: ReconciliationCheckpoint, + pub pending: Option, } impl LibraryApplicationRecord { @@ -77,11 +172,118 @@ impl LibraryApplicationRecord { Self { schema_version: LIBRARY_APPLICATION_SCHEMA_VERSION, current: LibraryApplicationState::default(), - pending_operation: None, + checkpoint: ReconciliationCheckpoint::default(), + pending: None, + } + } +} + +pub(crate) fn validate_application_record( + record: &LibraryApplicationRecord, +) -> Result<(), AppError> { + let invalid = || AppError::ConfigurationCorrupted { + message: "invalid Skill Library application record".to_string(), + }; + if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION + || has_duplicates(&record.current.ordered_library_ids) + || has_duplicates(&record.current.selected_agent_ids) + || !is_canonical_members(&record.checkpoint.members) + { + return Err(invalid()); + } + let Some(pending) = &record.pending else { + return Ok(()); + }; + if pending.reconciliation_id.is_empty() + || pending.reconciliation_id.contains(['/', '\\', '\0']) + || pending.reasons.is_empty() + || !is_strictly_sorted(&pending.reasons) + || pending.before_application != record.current + || has_duplicates(&pending.target_application.ordered_library_ids) + || has_duplicates(&pending.target_application.selected_agent_ids) + || !is_canonical_members(&pending.recognized_members) + || !is_canonical_members(&pending.affected_members) + || !is_canonical_members(&pending.target_members) + { + return Err(invalid()); + } + let recognized = pending.recognized_members.iter().collect::>(); + if record + .checkpoint + .members + .iter() + .chain(&pending.target_members) + .chain(&pending.affected_members) + .any(|member| !recognized.contains(member)) + { + return Err(invalid()); + } + Ok(()) +} + +fn has_duplicates(values: &[T]) -> bool { + values.iter().collect::>().len() != values.len() +} + +fn is_strictly_sorted(values: &[T]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn is_canonical_members(values: &[LibraryMemberIdentity]) -> bool { + values + .iter() + .all(|member| !member.library_id.as_str().is_empty() && !member.member_name.is_empty()) + && is_strictly_sorted(values) +} + +#[derive(Debug, Clone)] +pub struct VersionedApplicationRecord { + pub context: SkillLocationRef, + pub record: LibraryApplicationRecord, + pub(crate) target: ResourceLocator, + pub(crate) snapshot: DocumentSnapshot, +} + +#[derive(Debug)] +pub struct ApplicationInventoryProblem { + pub storage_key: String, + pub error: AppError, +} + +#[derive(Debug)] +pub struct ApplicationInventory { + pub records: Vec, + pub problems: Vec, + pub complete: bool, +} + +impl VersionedApplicationRecord { + #[cfg(test)] + pub(crate) fn in_memory(context: SkillLocationRef, record: LibraryApplicationRecord) -> Self { + let bytes = serde_json::to_vec_pretty(&record).expect("in-memory application record"); + Self { + target: ResourceLocator { + environment: context.environment.clone(), + native_path: format!("memory://{:?}", context.scope), + }, + snapshot: DocumentSnapshot { + bytes: Some(bytes), + generation: None, + }, + context, + record, } } } +impl std::ops::Deref for VersionedApplicationRecord { + type Target = LibraryApplicationRecord; + + fn deref(&self) -> &Self::Target { + &self.record + } +} + /// 判断某个库在这一份应用记录中的使用状态。 /// /// `None` 表示该位置既没有确认使用该库,也没有未完成操作引用它。已确认生效优先于 @@ -93,7 +295,7 @@ pub fn library_usage_state( if record.current.ordered_library_ids.contains(library_id) { return Some(LibraryUsageState::Confirmed); } - let pending = record.pending_operation.as_ref().is_some_and(|pending| { + let pending = record.pending.as_ref().is_some_and(|pending| { pending .before_application .ordered_library_ids @@ -122,7 +324,7 @@ impl LibraryUsageAccumulator { confirmed.insert(id); } let mut pending: BTreeSet<&LibraryId> = BTreeSet::new(); - if let Some(operation) = record.pending_operation.as_ref() { + if let Some(operation) = record.pending.as_ref() { let referenced = operation .before_application .ordered_library_ids @@ -163,6 +365,65 @@ pub struct LibraryApplicationSummary { pub ordered_libraries: Vec, pub selected_agent_ids: Vec, pub pending: bool, + pub sync_state: LibraryApplicationSyncState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum LibraryApplicationSyncState { + Synced, + Pending, + Unverified, + RecoveryRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScopeRecoveryState { + Clear, + Unverified, + Required, +} + +pub(crate) trait LibraryApplicationRecoveryStatus: Send + Sync { + fn status<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result>; +} + +#[cfg(test)] +struct ClearRecoveryStatus; + +#[cfg(test)] +impl LibraryApplicationRecoveryStatus for ClearRecoveryStatus { + fn status<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async { Ok(ScopeRecoveryState::Clear) }) + } +} + +fn application_sync_state( + record: &LibraryApplicationRecord, + target_members: &[LibraryMemberIdentity], + recovery: ScopeRecoveryState, +) -> LibraryApplicationSyncState { + if recovery == ScopeRecoveryState::Required { + LibraryApplicationSyncState::RecoveryRequired + } else if recovery == ScopeRecoveryState::Unverified + || record + .pending + .as_ref() + .is_some_and(|pending| pending.attention == ReconciliationAttention::Unverified) + { + LibraryApplicationSyncState::Unverified + } else if record.pending.is_some() || record.checkpoint.members != target_members { + LibraryApplicationSyncState::Pending + } else { + LibraryApplicationSyncState::Synced + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -223,6 +484,13 @@ pub struct LibraryApplicationResponse { pub units: Vec, } +#[derive(Debug, Clone)] +pub(crate) struct LibraryApplicationScopePlan { + pub context: SkillLocationRef, + pub entries: Vec<(PhysicalTargetKey, PreparedEntryAction)>, + pub preview: Option, +} + /// Project 删除流程解除库应用关系时使用的操作集合。 /// /// 该 Interface 只覆盖解除流程实际调用的五个操作,使用例可以在不构造 @@ -257,18 +525,25 @@ pub trait ProjectLibraryDetachment: Send + Sync { ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; } -pub trait LibraryApplicationRepository: Send + Sync { +pub trait ApplicationRegistry: Send + Sync { fn load_application<'a>( &'a self, context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result>; + ) -> LibraryApplicationFuture<'a, Result>; - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; + ) -> LibraryApplicationFuture<'a, Result>; + + fn enumerate<'a>( + &'a self, + environment: &'a crate::environment::types::EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result>; +} +pub(crate) trait LibraryApplicationResources: Send + Sync { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -281,17 +556,28 @@ pub trait LibraryApplicationRepository: Send + Sync { context: &'a SkillLocationRef, ) -> LibraryApplicationFuture<'a, Result>; - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>>; } +pub(crate) trait LibraryApplicationBackend: + ApplicationRegistry + LibraryApplicationResources +{ +} + +impl LibraryApplicationBackend for T where + T: ApplicationRegistry + LibraryApplicationResources + ?Sized +{ +} + pub struct LibraryApplicationModule { - repository: Arc, + repository: Arc, facts: F, targets: T, executor: E, + recovery: Arc, } impl LibraryApplicationModule @@ -300,17 +586,35 @@ where T: TargetFactResolver, E: MutationPlanExecutor, { + #[cfg(test)] pub fn new( - repository: Arc, + repository: Arc, + facts: F, + targets: T, + executor: E, + ) -> Self { + Self::with_recovery_status( + repository, + facts, + targets, + executor, + Arc::new(ClearRecoveryStatus), + ) + } + + pub fn with_recovery_status( + repository: Arc, facts: F, targets: T, executor: E, + recovery: Arc, ) -> Self { Self { repository, facts, targets, executor, + recovery, } } @@ -320,15 +624,27 @@ where ) -> Result { let record = self.repository.load_application(&context).await?; let catalog = self.repository.load_catalog(&context).await?; - summary(&record, &catalog) + let recovery = self + .recovery + .status(&context) + .await + .unwrap_or(ScopeRecoveryState::Unverified); + summary(&record, &catalog, recovery) } pub async fn agent_options( &self, context: SkillLocationRef, ) -> Result { + let record = self.repository.load_application(&context).await?; let facts = self.facts.snapshot(&context).await?; - let resolved = resolve_library_agent_options(&context, &facts, &self.targets).await?; + let resolved = resolve_library_agent_options( + &context, + &facts, + &self.targets, + &persisted_agent_ids(&record.record), + ) + .await?; Ok(LibraryAgentOptions { selection: resolved.selection, migrations: resolved.migrations, @@ -347,17 +663,29 @@ where let current = member_index .members_for(&record.current.ordered_library_ids) .map_err(library_member_index_error)?; - let pending = record - .pending_operation + let desired_ids = record + .pending .as_ref() - .map(|pending| { - member_index - .members_for(&pending.target_application.ordered_library_ids) - .map_err(library_member_index_error) - }) - .transpose()? - .unwrap_or_default(); - let groups = merge_library_skill_groups(current, pending); + .map(|pending| pending.target_application.ordered_library_ids.as_slice()) + .unwrap_or(&record.current.ordered_library_ids); + let target = member_index + .members_for(desired_ids) + .map_err(library_member_index_error)?; + let desired_members = target + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + let recognized = member_index + .recognized_members(&reconciliation_members( + &record.checkpoint, + record.pending.as_ref(), + &desired_members, + )) + .map_err(library_member_index_error)?; + let groups = merge_library_skill_groups(current, target, recognized.clone(), recognized); if groups.is_empty() { return Ok(BTreeSet::new()); } @@ -386,12 +714,7 @@ where .await?; let mut managed = BTreeSet::new(); for (group, fact) in groups.into_iter().zip(target_facts) { - let members = group - .current_members - .iter() - .chain(group.target_members.iter()) - .cloned() - .collect::>(); + let members = group.recognized_members; let candidates = candidate_index.candidates_for(&members)?; for candidate in candidates { if fact @@ -411,7 +734,7 @@ where &self, draft: LibraryApplicationDraft, ) -> Result { - Ok(self.build(&draft, false, false).await?.preview) + Ok(self.build(&draft, false, false, None, None).await?.preview) } pub async fn apply( @@ -419,19 +742,46 @@ where request: ApplyLibraryApplicationRequest, cancellation: CancellationSignal, ) -> Result { - let built = self.build(&request.draft, true, false).await?; + let built = self.build(&request.draft, true, false, None, None).await?; validate_exact_preview(&request.expected_token, &built.preview.token)?; + let mut reasons = reconciliation_reasons( + &built.record.current, + &built.preview.target, + &built.record.checkpoint, + &built.target_members, + ); + if reasons.is_empty() && built.plan.units.is_empty() { + return Ok(LibraryApplicationResponse { + application: self.read(built.context).await?, + units: Vec::new(), + }); + } + if built.record.current == built.preview.target && !built.plan.units.is_empty() { + reasons.push(ReconciliationReason::ReapplyRequested); + } let mut pending_record = built.record.clone(); - pending_record.pending_operation = Some(PendingLibraryApplication { - operation_id: built.plan.operation_id.clone(), + pending_record.pending = Some(PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, before_application: built.record.current.clone(), target_application: built.preview.target.clone(), - preview_fingerprint: built.preview.token.generation.clone(), + recognized_members: reconciliation_members( + &built.record.checkpoint, + built.record.pending.as_ref(), + &built.target_members, + ), + affected_members: changed_member_identities( + &built.record.checkpoint.members, + &built.target_members, + ), + target_members: built.target_members.clone(), }); - self.repository - .save_application(&built.context, &pending_record) + let pending = self + .repository + .save_application_if(&built.observed, &pending_record) .await?; - self.execute(built, pending_record, cancellation).await + self.execute(built, pending, cancellation).await } pub async fn retry_pending( @@ -439,22 +789,243 @@ where context: SkillLocationRef, cancellation: CancellationSignal, ) -> Result { - let record = self.repository.load_application(&context).await?; - let pending = record - .pending_operation - .clone() - .ok_or_else(|| AppError::Validation { + let observed = self.repository.load_application(&context).await?; + if observed.pending.is_none() { + return Err(AppError::Validation { field: Some("context".to_string()), message: "the Scope has no pending Skill Library operation".to_string(), - })?; + }); + } + self.resume_observed(context, observed, cancellation).await + } + + pub async fn resume( + &self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + self.resume_observed(context, observed, cancellation).await + } + + pub(crate) async fn plan_resume( + &self, + context: SkillLocationRef, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + let catalog = self.repository.load_catalog(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + return Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); + let draft = LibraryApplicationDraft { + context: context.clone(), + ordered_library_ids: target_application.ordered_library_ids, + selected_agent_ids: target_application.selected_agent_ids, + }; + let built = self.build(&draft, true, true, Some(observed), None).await?; + let entries = built + .plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .map(|entry| (entry.key.clone(), entry.action.clone())) + .collect(); + Ok(LibraryApplicationScopePlan { + context, + entries, + preview: Some(built.preview), + }) + } + + pub(crate) async fn plan_resume_with_catalog( + &self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> Result { + let observed = self.repository.load_application(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + return Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); + let draft = LibraryApplicationDraft { + context: context.clone(), + ordered_library_ids: target_application.ordered_library_ids, + selected_agent_ids: target_application.selected_agent_ids, + }; + let built = self + .build(&draft, true, true, Some(observed), Some(catalog)) + .await?; + let entries = built + .plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .map(|entry| (entry.key.clone(), entry.action.clone())) + .collect(); + Ok(LibraryApplicationScopePlan { + context, + entries, + preview: Some(built.preview), + }) + } + + pub(crate) async fn record_reconciliation_attention( + &self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> Result<(), AppError> { + let observed = self.repository.load_application(&context).await?; + let catalog = self.repository.load_catalog(&context).await?; + let member_index = + LibraryCatalogMemberIndex::build(&catalog).map_err(library_member_index_error)?; + let target_application = observed + .record + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.record.current.clone()); + let target_members = member_index + .members_for(&target_application.ordered_library_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); + let reasons = reconciliation_reasons( + &observed.record.current, + &target_application, + &observed.record.checkpoint, + &target_members, + ); + if reasons.is_empty() && observed.record.pending.is_none() { + return Ok(()); + } + let recognized = reconciliation_members( + &observed.record.checkpoint, + observed.record.pending.as_ref(), + &target_members, + ); + let mut pending = match observed.record.pending.clone() { + Some(pending) => { + pending.merged_with(reasons, target_application, recognized, target_members) + } + None => PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, + before_application: observed.record.current.clone(), + target_application, + recognized_members: recognized, + affected_members: changed_member_identities( + &observed.record.checkpoint.members, + &target_members, + ), + target_members, + }, + }; + pending.attention = pending.attention.max(attention); + let mut record = observed.record.clone(); + record.pending = Some(pending); + self.repository + .save_application_if(&observed, &record) + .await?; + Ok(()) + } + + async fn resume_observed( + &self, + context: SkillLocationRef, + observed: VersionedApplicationRecord, + cancellation: CancellationSignal, + ) -> Result { + let catalog = self.repository.load_catalog(&context).await?; + if !reconciliation_required(&observed.record, &catalog)? { + if observed.pending.is_some() { + let mut verified = observed.record.clone(); + verified.pending = None; + self.repository + .save_application_if(&observed, &verified) + .await?; + } + return Ok(LibraryApplicationResponse { + application: self.read(context).await?, + units: Vec::new(), + }); + } + let target_application = observed + .pending + .as_ref() + .map(|pending| pending.target_application.clone()) + .unwrap_or_else(|| observed.current.clone()); let draft = LibraryApplicationDraft { context, - ordered_library_ids: pending.target_application.ordered_library_ids.clone(), - selected_agent_ids: pending.target_application.selected_agent_ids.clone(), + ordered_library_ids: target_application.ordered_library_ids.clone(), + selected_agent_ids: target_application.selected_agent_ids.clone(), + }; + let built = self.build(&draft, true, true, Some(observed), None).await?; + let reasons = reconciliation_reasons( + &built.record.current, + &built.preview.target, + &built.record.checkpoint, + &built.target_members, + ); + if reasons.is_empty() && built.record.pending.is_none() { + return Ok(LibraryApplicationResponse { + application: self.read(built.context).await?, + units: Vec::new(), + }); + } + let recognized = reconciliation_members( + &built.record.checkpoint, + built.record.pending.as_ref(), + &built.target_members, + ); + let pending = match built.record.pending.clone() { + Some(pending) => pending.merged_with( + reasons, + built.preview.target.clone(), + recognized, + built.target_members.clone(), + ), + None => PendingReconciliation { + reconciliation_id: uuid::Uuid::new_v4().simple().to_string(), + attention: ReconciliationAttention::Pending, + reasons, + before_application: built.record.current.clone(), + target_application: built.preview.target.clone(), + recognized_members: recognized, + affected_members: changed_member_identities( + &built.record.checkpoint.members, + &built.target_members, + ), + target_members: built.target_members.clone(), + }, }; - let mut built = self.build(&draft, true, true).await?; - built.plan.operation_id = pending.operation_id; - self.execute(built, record, cancellation).await + let mut pending_record = built.record.clone(); + pending_record.pending = Some(pending); + let pending = self + .repository + .save_application_if(&built.observed, &pending_record) + .await?; + self.execute(built, pending, cancellation).await } pub async fn forget_project(&self, context: SkillLocationRef) -> Result<(), AppError> { @@ -468,19 +1039,20 @@ where }); } let record = self.repository.load_application(&context).await?; - if record.pending_operation.is_some() + if record.pending.is_some() || !record.current.ordered_library_ids.is_empty() || !record.current.selected_agent_ids.is_empty() + || !record.checkpoint.members.is_empty() { return Err(AppError::MutationBusy); } - self.repository.remove_application(&context).await + self.repository.remove_application_if(&record).await } async fn execute( &self, built: BuiltLibraryApplication, - pending_record: LibraryApplicationRecord, + pending: VersionedApplicationRecord, cancellation: CancellationSignal, ) -> Result { let context = built.context.clone(); @@ -490,18 +1062,30 @@ where let final_record = if completed { LibraryApplicationRecord { current: built.preview.target, - pending_operation: None, - ..pending_record + checkpoint: ReconciliationCheckpoint { + members: built.target_members.clone(), + }, + pending: None, + ..pending.record.clone() } } else { - pending_record + let mut record = pending.record.clone(); + if let Some(pending) = &mut record.pending { + pending.attention = execution_attention(&units); + } + record }; self.repository - .save_application(&context, &final_record) + .save_application_if(&pending, &final_record) .await?; let catalog = self.repository.load_catalog(&context).await?; + let recovery = self + .recovery + .status(&context) + .await + .unwrap_or(ScopeRecoveryState::Unverified); Ok(LibraryApplicationResponse { - application: summary(&final_record, &catalog)?, + application: summary(&final_record, &catalog, recovery)?, units, }) } @@ -511,17 +1095,32 @@ where draft: &LibraryApplicationDraft, include_plan: bool, allow_pending: bool, + observed: Option, + catalog: Option, ) -> Result { - let record = self.repository.load_application(&draft.context).await?; - if record.pending_operation.is_some() && !allow_pending { + let observed = match observed { + Some(observed) => observed, + None => self.repository.load_application(&draft.context).await?, + }; + let record = observed.record.clone(); + if record.pending.is_some() && !allow_pending { return Err(AppError::MutationBusy); } - let catalog = self.repository.load_catalog(&draft.context).await?; - let member_index = + let observed_catalog = self.repository.load_catalog(&draft.context).await?; + let catalog = catalog.unwrap_or_else(|| observed_catalog.clone()); + let current_member_index = LibraryCatalogMemberIndex::build(&observed_catalog) + .map_err(library_member_index_error)?; + let target_member_index = LibraryCatalogMemberIndex::build(&catalog).map_err(library_member_index_error)?; let facts = self.facts.snapshot(&draft.context).await?; - let agent_options = - resolve_library_agent_options(&draft.context, &facts, &self.targets).await?; + let persisted_agent_ids = persisted_agent_ids(&record); + let agent_options = resolve_library_agent_options( + &draft.context, + &facts, + &self.targets, + &persisted_agent_ids, + ) + .await?; let ordered_library_ids = validated_library_ids(&catalog, &draft.ordered_library_ids)?; if ordered_library_ids.is_empty() && !draft.selected_agent_ids.is_empty() { return Err(AppError::Validation { @@ -534,25 +1133,83 @@ where selected_agent_ids: validated_agent_ids( &agent_options.placement_map, &draft.selected_agent_ids, + &persisted_agent_ids, )?, }; - let current_agent_ids = validated_agent_ids( - &agent_options.placement_map, - &record.current.selected_agent_ids, - )?; - if let Some(pending) = &record.pending_operation { + let current_agent_ids = record.current.selected_agent_ids.clone(); + if let Some(pending) = &record.pending { if pending.target_application != target { return Err(AppError::StaleContext); } } - let groups = merge_library_skill_groups( - member_index - .members_for(&record.current.ordered_library_ids) + let current_members = current_member_index + .members_for(&record.current.ordered_library_ids) + .map_err(library_member_index_error)?; + let current_member_identities = current_members + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + let target_members_by_directory = target_member_index + .members_for(&target.ordered_library_ids) + .map_err(library_member_index_error)?; + let target_members = target_members_by_directory + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + let recognized_members = + reconciliation_members(&record.checkpoint, record.pending.as_ref(), &target_members); + let recognized = merge_catalog_member_maps( + current_member_index + .recognized_members( + &recognized_members + .iter() + .filter(|member| current_member_index.contains_identity(member)) + .cloned() + .collect::>(), + ) .map_err(library_member_index_error)?, - member_index - .members_for(&target.ordered_library_ids) + target_member_index + .recognized_members( + &recognized_members + .iter() + .filter(|member| target_member_index.contains_identity(member)) + .cloned() + .collect::>(), + ) .map_err(library_member_index_error)?, ); + let before_recognized = current_member_index + .recognized_members(&reconciliation_members( + &record.checkpoint, + record.pending.as_ref(), + ¤t_member_identities, + )) + .map_err(library_member_index_error)?; + let mut groups = merge_library_skill_groups( + current_members, + target_members_by_directory, + recognized, + before_recognized, + ); + let confirmed_reapply = record.pending.as_ref().is_some_and(|pending| { + pending + .reasons + .contains(&ReconciliationReason::ReapplyRequested) + }); + if allow_pending && !confirmed_reapply && record.current == target { + let changed = membership_changed_skill_directories( + &record.checkpoint, + record.pending.as_ref(), + &target_members, + )?; + groups.retain(|group| changed.contains(&group.directory_name)); + } let candidate_members = library_group_members(&groups); let candidate_index = ResolvedLibraryCandidateIndex::load( self.repository.as_ref(), @@ -572,17 +1229,22 @@ where for group in groups { let current_candidates = candidate_index.candidates_for(&group.current_members)?; let target_candidates = candidate_index.candidates_for(&group.target_members)?; + let recognized_candidates = + candidate_index.candidates_for(&group.recognized_members)?; + let before_recognized_candidates = + candidate_index.candidates_for(&group.before_recognized_members)?; + let before_applied = !before_recognized_candidates.is_empty(); let current_candidate_set = LibraryCandidateSet::for_skill( &draft.context.environment, &group.directory_name, - current_candidates.clone(), + before_recognized_candidates, current_candidates, ) .map_err(|_| AppError::StaleContext)?; let target_candidate_set = LibraryCandidateSet::for_skill( &draft.context.environment, &group.directory_name, - target_candidates.clone(), + recognized_candidates, target_candidates, ) .map_err(|_| AppError::StaleContext)?; @@ -635,6 +1297,7 @@ where group, current_candidate_set, target_candidate_set, + before_applied, logical_targets, )); } @@ -656,7 +1319,8 @@ where let mut removed_skill_names = BTreeSet::new(); let mut switched_skill_names = BTreeSet::new(); let mut observed_targets = Vec::new(); - for (group, current_candidate_set, target_candidate_set, logical_targets) in prepared_groups + for (group, current_candidate_set, target_candidate_set, before_applied, logical_targets) in + prepared_groups { let group_facts = target_facts .by_ref() @@ -694,6 +1358,7 @@ where candidates: &target_candidate_set, selected_agent_ids: &target.selected_agent_ids, }, + before_applied, legacy, }) .map_err(|error| error.into_app_error())?; @@ -785,7 +1450,9 @@ where overridden_by_direct_skill_names: direct_skill_names.into_iter().collect(), }, record, + observed, plan, + target_members, }) } } @@ -800,6 +1467,22 @@ fn library_execution_completed(expected: usize, units: &[MutationUnitResult]) -> }) } +fn execution_attention(units: &[MutationUnitResult]) -> ReconciliationAttention { + if units.iter().any(|unit| { + unit.error.as_ref().is_some_and(|error| { + matches!( + error.code, + crate::application::mutation::result::OperationErrorCode::EnvironmentUnavailable + | crate::application::mutation::result::OperationErrorCode::StorageUnsupported + ) + }) + }) { + ReconciliationAttention::Unverified + } else { + ReconciliationAttention::Pending + } +} + fn library_application_observed_digest( record: &LibraryApplicationRecord, target: &LibraryApplicationState, @@ -827,9 +1510,7 @@ where context: SkillLocationRef, cancellation: CancellationSignal, ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { - LibraryApplicationModule::retry_pending(self, context, cancellation).await - }) + Box::pin(async move { LibraryApplicationModule::resume(self, context, cancellation).await }) } fn preview<'a>( @@ -876,6 +1557,8 @@ struct LibrarySkillGroup { directory_name: SkillDirectoryName, current_members: Vec, target_members: Vec, + recognized_members: Vec, + before_recognized_members: Vec, } #[derive(Clone)] @@ -894,6 +1577,7 @@ async fn resolve_library_agent_options( context: &SkillLocationRef, facts: &ScopePlanningSnapshot, targets: &T, + saved_agent_ids: &BTreeSet, ) -> Result { let catalog = build_agent_selection_catalog( context, @@ -904,12 +1588,36 @@ async fn resolve_library_agent_options( ) .await?; let placement_map = LibraryAgentPlacementMap::from_catalog(&catalog); - let selection = placement_map.selection_snapshot().clone(); - let private_agent_ids = selection + let mut selection = placement_map.selection_snapshot().clone(); + let represented_saved_agent_ids = selection + .install_options + .iter() + .filter(|option| { + option + .agent_ids + .iter() + .all(|agent_id| saved_agent_ids.contains(agent_id)) + }) + .flat_map(|option| option.agent_ids.iter().cloned()) + .collect::>(); + let known_agent_ids = catalog + .snapshot() .agents .iter() .map(|agent| agent.id.clone()) .collect::>(); + selection.unavailable_explicit_agents = saved_agent_ids + .iter() + .filter(|agent_id| !represented_saved_agent_ids.contains(*agent_id)) + .map(|agent_id| UnavailableAgentSelection { + agent_id: agent_id.as_str().to_string(), + reason: if known_agent_ids.contains(agent_id) { + UnavailableAgentSelectionReason::OptionUnavailable + } else { + UnavailableAgentSelectionReason::DefinitionMissing + }, + }) + .collect(); let mut options = placement_map .placements() .filter_map(|(placement_id, placement)| match placement_id { @@ -955,11 +1663,8 @@ async fn resolve_library_agent_options( }; let mut legacy_candidates = Vec::new(); let mut migrations = Vec::new(); - for agent in &selection.agents { - if !private_agent_ids.contains(&agent.id) { - continue; - } - let Some(resolved) = facts.agent_runtime.agents.get(&agent.id) else { + for agent_id in saved_agent_ids { + let Some(resolved) = facts.agent_runtime.agents.get(agent_id) else { continue; }; let resolved_scope = if legacy_scope == LegacyPathScope::Global { @@ -982,7 +1687,11 @@ async fn resolve_library_agent_options( environment: context.environment.clone(), native_path: path.clone(), }; - legacy_candidates.push((root, agent.id.clone(), agent.display_name.clone())); + legacy_candidates.push(( + root, + agent_id.clone(), + resolved.definition.display_name.clone(), + )); } } } @@ -1041,9 +1750,10 @@ async fn resolve_library_agent_options( fn validated_agent_ids( placements: &LibraryAgentPlacementMap, requested: &[AgentId], + persisted: &BTreeSet, ) -> Result, AppError> { placements - .placements_for(requested) + .validate_selection_with_persisted(requested, persisted) .map_err(|error| match error { crate::application::library_agent_placements::LibraryAgentPlacementError::UnknownAgent( agent, @@ -1064,6 +1774,22 @@ fn validated_agent_ids( .collect()) } +fn persisted_agent_ids(record: &LibraryApplicationRecord) -> BTreeSet { + record + .current + .selected_agent_ids + .iter() + .chain(record.pending.iter().flat_map(|pending| { + pending + .before_application + .selected_agent_ids + .iter() + .chain(&pending.target_application.selected_agent_ids) + })) + .cloned() + .collect() +} + fn ensure_library_link_targets_supported<'a>( targets: impl IntoIterator, ) -> Result<(), AppError> { @@ -1083,16 +1809,96 @@ struct BuiltLibraryApplication { context: SkillLocationRef, preview: LibraryApplicationPreview, record: LibraryApplicationRecord, + observed: VersionedApplicationRecord, plan: crate::application::mutation::plan::MutationPlan, + target_members: Vec, } -fn merge_library_skill_groups( - current: BTreeMap>, - target: BTreeMap>, -) -> Vec { +fn reconciliation_reasons( + current: &LibraryApplicationState, + target: &LibraryApplicationState, + checkpoint: &ReconciliationCheckpoint, + target_members: &[LibraryMemberIdentity], +) -> Vec { + let mut reasons = Vec::new(); + if current != target { + reasons.push(ReconciliationReason::ApplicationChanged); + } + if checkpoint.members != target_members { + reasons.push(ReconciliationReason::MembershipChanged); + } + reasons +} + +fn reconciliation_required( + record: &LibraryApplicationRecord, + catalog: &LibraryCatalog, +) -> Result { + if record.pending.as_ref().is_some_and(|pending| { + pending.target_application != record.current + || pending + .reasons + .iter() + .any(|reason| *reason != ReconciliationReason::VerificationRequired) + }) { + return Ok(true); + } + let target = record + .pending + .as_ref() + .map(|pending| &pending.target_application) + .unwrap_or(&record.current); + let target_members = LibraryCatalogMemberIndex::build(catalog) + .map_err(library_member_index_error)? + .members_for(&target.ordered_library_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); + Ok(record.checkpoint.members != target_members) +} + +fn membership_changed_skill_directories( + checkpoint: &ReconciliationCheckpoint, + pending: Option<&PendingReconciliation>, + target_members: &[LibraryMemberIdentity], +) -> Result, AppError> { + let mut affected = changed_member_identities(&checkpoint.members, target_members); + if let Some(pending) = pending { + affected.extend(pending.affected_members.iter().cloned()); + affected.extend(changed_member_identities( + &pending.target_members, + target_members, + )); + } + affected + .into_iter() + .map(|member| SkillDirectoryName::try_from(member.member_name.as_str())) + .collect() +} + +fn changed_member_identities( + before: &[LibraryMemberIdentity], + after: &[LibraryMemberIdentity], +) -> Vec { + let before = before.iter().cloned().collect::>(); + let after = after.iter().cloned().collect::>(); + before.symmetric_difference(&after).cloned().collect() +} + +fn merge_library_skill_groups( + current: BTreeMap>, + target: BTreeMap>, + recognized: BTreeMap>, + before_recognized: BTreeMap>, +) -> Vec { let directory_names = current .keys() .chain(target.keys()) + .chain(recognized.keys()) + .chain(before_recognized.keys()) .cloned() .collect::>(); directory_names @@ -1100,20 +1906,33 @@ fn merge_library_skill_groups( .map(|directory_name| LibrarySkillGroup { current_members: current.get(&directory_name).cloned().unwrap_or_default(), target_members: target.get(&directory_name).cloned().unwrap_or_default(), + recognized_members: recognized.get(&directory_name).cloned().unwrap_or_default(), + before_recognized_members: before_recognized + .get(&directory_name) + .cloned() + .unwrap_or_default(), directory_name, }) .collect() } +fn merge_catalog_member_maps( + mut left: BTreeMap>, + right: BTreeMap>, +) -> BTreeMap> { + for (directory, members) in right { + let combined = left.entry(directory).or_default(); + combined.extend(members); + combined.sort(); + combined.dedup(); + } + left +} + fn library_group_members(groups: &[LibrarySkillGroup]) -> Vec { groups .iter() - .flat_map(|group| { - group - .current_members - .iter() - .chain(group.target_members.iter()) - }) + .flat_map(|group| group.recognized_members.iter()) .cloned() .collect() } @@ -1138,12 +1957,7 @@ fn validated_library_ids( .ok_or_else(|| AppError::PathNotFound { path: id.as_str().to_string(), })?; - if library.skills.is_empty() { - return Err(AppError::Validation { - field: Some("orderedLibraryIds".to_string()), - message: "empty Skill Library cannot be applied".to_string(), - }); - } + let _ = library; result.push(id.clone()); } Ok(result) @@ -1158,7 +1972,23 @@ fn library_member_index_error(error: impl std::fmt::Debug) -> AppError { fn summary( record: &LibraryApplicationRecord, catalog: &LibraryCatalog, + recovery: ScopeRecoveryState, ) -> Result { + let member_index = + LibraryCatalogMemberIndex::build(catalog).map_err(library_member_index_error)?; + let target_ids = record + .pending + .as_ref() + .map(|pending| pending.target_application.ordered_library_ids.as_slice()) + .unwrap_or(&record.current.ordered_library_ids); + let target_members = member_index + .members_for(target_ids) + .map_err(library_member_index_error)? + .into_values() + .flatten() + .collect::>() + .into_iter() + .collect::>(); let mut ordered_libraries = Vec::new(); for id in &record.current.ordered_library_ids { let library = catalog @@ -1174,32 +2004,36 @@ fn summary( skill_count: library.skills.len() as u32, }); } + let sync_state = application_sync_state(record, &target_members, recovery); Ok(LibraryApplicationSummary { ordered_libraries, selected_agent_ids: record.current.selected_agent_ids.clone(), - pending: record.pending_operation.is_some(), + pending: sync_state != LibraryApplicationSyncState::Synced, + sync_state, }) } #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use crate::application::install::InstallFuture; use crate::application::mutation::executor::MutationFuture; use crate::application::mutation::plan::{MutationPlan, PreparedEntryAction, RuntimeRevisions}; use crate::application::skill_libraries::{ - LibrarySkillRecord, LibrarySkillSourceRecord, SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, + LibrarySkillRecord, LibrarySkillSourceRecord, RetiredLibrarySkillRecord, + SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, }; use crate::core::agent_definition::{ - AgentAdapter, AgentDefinition, AgentSource, DetectionSpec, PathSpec, ScopeDefinition, + AgentAdapter, AgentDefinition, AgentSource, DetectionSpec, LegacyMigrationTarget, + LegacyPath, PathSpec, ScopeDefinition, }; use crate::core::lossless_lock::{LockSchema, LosslessLockDocument}; use crate::environment::agent_environment::{ AgentRuntimeSnapshot, DetectionState, DirectoryPresenceState, ResolvedAgent, - ResolvedAgentScope, + ResolvedAgentScope, ResolvedPathPresence, }; use crate::environment::context_resolver::ResolvedContext; use crate::environment::planning::{TargetFactFuture, TargetFactResolver}; @@ -1213,11 +2047,23 @@ mod tests { const TEST_SKILL_ROOT: &str = "/scope/.agents/skills"; const TEST_AGENT_ROOT: &str = "/agents/private/skills"; + const TEST_LEGACY_ROOT: &str = "/agents/legacy/skills"; const TEST_LIBRARY_ROOT: &str = "/libraries/lib-one/skills"; #[derive(Clone)] struct FixedFacts(ScopePlanningSnapshot); + struct FixedRecoveryStatus(ScopeRecoveryState); + + impl LibraryApplicationRecoveryStatus for FixedRecoveryStatus { + fn status<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { Ok(self.0) }) + } + } + impl ScopePlanningSnapshotSource for FixedFacts { fn snapshot<'a>( &'a self, @@ -1230,27 +2076,65 @@ mod tests { struct MemoryApplicationRepository { record: Mutex, catalog: Mutex, + fail_next_checkpoint: AtomicBool, } - impl LibraryApplicationRepository for MemoryApplicationRepository { + impl ApplicationRegistry for MemoryApplicationRepository { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.lock().unwrap().clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.lock().unwrap().clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { + if record.pending.is_none() + && self.fail_next_checkpoint.swap(false, Ordering::SeqCst) + { + return Err(AppError::Io { + message: "injected checkpoint commit failure".to_string(), + }); + } *self.record.lock().unwrap() = record.clone(); - Ok(()) + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let context = SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }; + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + context, + self.record.lock().unwrap().clone(), + )], + problems: Vec::new(), + complete: true, + }) }) } + } + impl LibraryApplicationResources for MemoryApplicationRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -1272,9 +2156,9 @@ mod tests { Box::pin(async move { Ok(self.catalog.lock().unwrap().clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { Ok(()) }) } @@ -1320,18 +2204,29 @@ mod tests { Ok(logical_destinations .iter() .map(|destination| { - let agent_skill = locator(TEST_AGENT_ROOT).join_child("demo"); + let path = std::path::Path::new(&destination.native_path); + let skill_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("target"); + let parent = path.parent(); let (name, entry_kind, link_target) = if destination.native_path == TEST_AGENT_ROOT { - ("agent-root", TargetEntryKind::Directory, None) - } else if destination.native_path == agent_skill.native_path { + ("agent-root".to_string(), TargetEntryKind::Directory, None) + } else if parent == Some(std::path::Path::new(TEST_AGENT_ROOT)) + || parent == Some(std::path::Path::new(TEST_LEGACY_ROOT)) + { ( - "agent-skill", + format!("agent-skill-{skill_name}"), self.agent_entry_kind, self.agent_link_target.clone(), ) } else { - ("canonical-skill", self.primary_entry_kind, None) + ( + format!("canonical-skill-{skill_name}"), + self.primary_entry_kind, + None, + ) }; let link_target_identity = link_target.as_deref().and_then(|raw| { crate::environment::planning::resolve_link_target_identity( @@ -1340,7 +2235,7 @@ mod tests { ) }); ResolvedTargetFact { - key: physical_key(name), + key: physical_key(&name), destination: destination.clone(), storage_access: StorageAccess::Native, fingerprint: EntryFingerprint(format!("entry-v1-{name}")), @@ -1389,6 +2284,37 @@ mod tests { } } + #[derive(Clone)] + struct RecoveryExecutor; + + impl MutationPlanExecutor for RecoveryExecutor { + fn execute<'a>( + &'a self, + plan: MutationPlan, + _cancellation: CancellationSignal, + ) -> MutationFuture<'a, Vec> { + Box::pin(async move { + plan.units + .iter() + .map(|unit| { + MutationUnitResult::recovery_required( + unit.id.clone(), + unit.skill_name.clone(), + unit.target.clone(), + crate::application::mutation::result::ErrorReport::recovery_required( + crate::application::mutation::result::RecoveryResourceId::parse( + "recovery-1", + ) + .unwrap(), + "restore required", + ), + ) + }) + .collect() + }) + } + } + fn application_fixture( agent_entry_kind: TargetEntryKind, agent_link_target: Option<&str>, @@ -1397,7 +2323,7 @@ mod tests { RecordingExecutor, LibraryApplicationDraft, ) { - let (module, executor, draft, _) = application_fixture_with( + let (module, executor, draft, _, _) = application_fixture_with( TargetEntryKind::Missing, agent_entry_kind, agent_link_target, @@ -1405,6 +2331,7 @@ mod tests { id: LibraryId::parse("lib-one"), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], Vec::new(), @@ -1413,6 +2340,14 @@ mod tests { (module, executor, draft) } + type ApplicationFixture = ( + LibraryApplicationModule, + RecordingExecutor, + LibraryApplicationDraft, + Arc, + Arc, + ); + fn application_fixture_with( primary_entry_kind: TargetEntryKind, agent_entry_kind: TargetEntryKind, @@ -1420,12 +2355,7 @@ mod tests { libraries: Vec, current_library_ids: Vec, target_library_ids: Vec, - ) -> ( - LibraryApplicationModule, - RecordingExecutor, - LibraryApplicationDraft, - Arc, - ) { + ) -> ApplicationFixture { let context = SkillLocationRef { environment: EnvironmentRef::Native, scope: SkillLocation::Global, @@ -1508,24 +2438,46 @@ mod tests { eve_targets: Vec::new(), }); let current_agent_ids = (!current_library_ids.is_empty()).then(|| agent_id.clone()); + let current_ids = current_library_ids.iter().collect::>(); + let checkpoint = libraries + .iter() + .filter(|library| current_ids.contains(&library.id)) + .flat_map(|library| { + library + .skills + .iter() + .chain(library.retired_skills.iter().map(|retired| &retired.member)) + .map(|member| LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }) + }) + .collect::>() + .into_iter() + .collect(); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries, + extra: serde_json::Map::new(), + }; let repository = Arc::new(MemoryApplicationRepository { record: Mutex::new(LibraryApplicationRecord { current: LibraryApplicationState { ordered_library_ids: current_library_ids, selected_agent_ids: current_agent_ids.into_iter().collect(), }, + checkpoint: ReconciliationCheckpoint { + members: checkpoint, + }, ..LibraryApplicationRecord::empty() }), - catalog: Mutex::new(LibraryCatalog { - schema_version: LIBRARY_SCHEMA_VERSION, - libraries, - extra: serde_json::Map::new(), - }), + catalog: Mutex::new(catalog), + fail_next_checkpoint: AtomicBool::new(false), }); let executor = RecordingExecutor::default(); let resolve_calls = Arc::new(TargetResolveCalls::default()); let module = LibraryApplicationModule::new( - repository, + repository.clone(), facts, FixedTargets { primary_entry_kind, @@ -1540,7 +2492,7 @@ mod tests { ordered_library_ids: target_library_ids, selected_agent_ids: vec![agent_id], }; - (module, executor, draft, resolve_calls) + (module, executor, draft, resolve_calls, repository) } async fn applied_result( @@ -1574,8 +2526,11 @@ mod tests { ordered_library_ids: ids(current), selected_agent_ids: Vec::new(), }, - pending_operation: pending.map(|(before, target)| PendingLibraryApplication { - operation_id: "operation".to_string(), + checkpoint: Default::default(), + pending: pending.map(|(before, target)| PendingReconciliation { + reconciliation_id: "reconciliation".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ApplicationChanged], before_application: LibraryApplicationState { ordered_library_ids: ids(before), selected_agent_ids: Vec::new(), @@ -1584,7 +2539,9 @@ mod tests { ordered_library_ids: ids(target), selected_agent_ids: Vec::new(), }, - preview_fingerprint: "fingerprint".to_string(), + recognized_members: Vec::new(), + affected_members: Vec::new(), + target_members: Vec::new(), }), } } @@ -1608,7 +2565,7 @@ mod tests { } #[test] - fn a_library_leaving_in_a_pending_operation_still_counts_as_locked() { + fn a_library_leaving_in_a_pending_still_counts_as_locked() { // 目标状态已经不含该库,但操作尚未完成,成员仍需锁定。 let record = record_with(&[], Some((&["leaving"], &[]))); @@ -1660,7 +2617,111 @@ mod tests { } #[test] - fn resolves_one_library_and_rejects_an_empty_library() { + fn empty_application_uses_only_the_final_schema_fields() { + let value = serde_json::to_value(LibraryApplicationRecord::empty()).unwrap(); + + assert_eq!(value["schemaVersion"], LIBRARY_APPLICATION_SCHEMA_VERSION); + assert_eq!(value["current"]["orderedLibraryIds"], serde_json::json!([])); + assert_eq!(value["checkpoint"]["members"], serde_json::json!([])); + assert!(value["pending"].is_null()); + assert!(value.get("pendingOperation").is_none()); + } + + #[test] + fn sync_state_uses_recovery_unverified_pending_synced_priority() { + let mut record = record_with(&["library-a"], Some((&["library-a"], &["library-a"]))); + record.pending.as_mut().unwrap().attention = ReconciliationAttention::Unverified; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-a"), + name: "Library A".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert_eq!( + summary(&record, &catalog, ScopeRecoveryState::Required) + .unwrap() + .sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + assert_eq!( + summary(&record, &catalog, ScopeRecoveryState::Clear) + .unwrap() + .sync_state, + LibraryApplicationSyncState::Unverified + ); + } + + #[test] + fn recovery_facts_override_persisted_pending_attention() { + let record = record_with(&["library-a"], Some((&["library-a"], &["library-a"]))); + + assert_eq!( + application_sync_state(&record, &[], ScopeRecoveryState::Required), + LibraryApplicationSyncState::RecoveryRequired + ); + } + + #[tokio::test] + async fn application_read_uses_the_recovery_status_source() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::Missing, None); + let LibraryApplicationModule { + repository, + facts, + targets, + executor, + .. + } = module; + let module = LibraryApplicationModule::with_recovery_status( + repository, + facts, + targets, + executor, + Arc::new(FixedRecoveryStatus(ScopeRecoveryState::Required)), + ); + + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + } + + #[test] + fn summary_reports_membership_drift_without_a_pending_record() { + let library_id = LibraryId::parse("library-one"); + let record = LibraryApplicationRecord { + current: LibraryApplicationState { + ordered_library_ids: vec![library_id.clone()], + selected_agent_ids: Vec::new(), + }, + ..LibraryApplicationRecord::empty() + }; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert!( + summary(&record, &catalog, ScopeRecoveryState::Clear) + .unwrap() + .pending + ); + } + + #[test] + fn resolves_members_and_keeps_an_empty_library_selectable() { let filled_id = LibraryId::parse("filled"); let empty_id = LibraryId::parse("empty"); let catalog = LibraryCatalog { @@ -1670,12 +2731,14 @@ mod tests { id: filled_id.clone(), name: "Backend".to_string(), skills: vec![skill("api-design")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: empty_id.clone(), name: "Empty".to_string(), skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1693,7 +2756,10 @@ mod tests { .library_id, filled_id ); - assert!(validated_library_ids(&catalog, &[empty_id]).is_err()); + assert_eq!( + validated_library_ids(&catalog, std::slice::from_ref(&empty_id)).unwrap(), + vec![empty_id] + ); } #[test] @@ -1707,12 +2773,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1742,12 +2810,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("CE:Review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1764,7 +2834,7 @@ mod tests { async fn switching_physical_aliases_uses_one_task_and_the_target_member() { let first = LibraryId::parse("first"); let second = LibraryId::parse("second"); - let (module, executor, draft, resolve_calls) = application_fixture_with( + let (module, executor, draft, resolve_calls, _) = application_fixture_with( TargetEntryKind::Missing, TargetEntryKind::Missing, None, @@ -1773,12 +2843,14 @@ mod tests { id: first.clone(), name: "First".to_string(), skills: vec![skill("CE:Review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, SkillLibraryRecord { id: second.clone(), name: "Second".to_string(), skills: vec![skill("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }, ], @@ -1807,7 +2879,7 @@ mod tests { #[tokio::test] async fn multi_skill_application_resolves_candidates_and_placements_once() { let library_id = LibraryId::parse("library-one"); - let (module, _executor, draft, resolve_calls) = application_fixture_with( + let (module, _executor, draft, resolve_calls, _) = application_fixture_with( TargetEntryKind::Missing, TargetEntryKind::Missing, None, @@ -1815,6 +2887,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], Vec::new(), @@ -1842,6 +2915,7 @@ mod tests { id: LibraryId::parse("lib-one"), name: "Library One".to_string(), skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -1881,55 +2955,354 @@ mod tests { } #[tokio::test] - async fn applying_a_library_replaces_a_broken_agent_link() { - let (module, executor, draft) = - application_fixture(TargetEntryKind::BrokenLink, Some("/missing/direct-skill")); + async fn reapplying_an_unchanged_application_repairs_a_missing_library_link() { + let (module, executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); - let (plan, _response) = applied_result(&module, &executor, draft).await; + let (plan, response) = applied_result(&module, &executor, draft).await; - assert_eq!(plan.units.len(), 1); - assert!(matches!( - plan.units[0] - .additional_entries - .first() - .map(|entry| &entry.action), - Some(PreparedEntryAction::Link { target }) - if target.native_path == format!("{TEST_LIBRARY_ROOT}/demo") - )); + assert_eq!(response.units.len(), 1); + assert!(plan.units[0] + .primary_entry + .as_ref() + .is_some_and(|entry| { matches!(entry.action, PreparedEntryAction::Link { .. }) })); } #[tokio::test] - async fn applying_a_library_preserves_a_valid_direct_agent_link() { - let (module, executor, draft) = - application_fixture(TargetEntryKind::Symlink, Some("/direct-skill/demo")); + async fn verification_only_resume_does_not_repair_an_unconfirmed_directory_change() { + let (mut module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + { + let mut record = repository.record.lock().unwrap(); + record.pending = Some(PendingReconciliation { + reconciliation_id: "verification-only".to_string(), + attention: ReconciliationAttention::Unverified, + reasons: vec![ReconciliationReason::VerificationRequired], + before_application: record.current.clone(), + target_application: record.current.clone(), + recognized_members: record.checkpoint.members.clone(), + affected_members: Vec::new(), + target_members: record.checkpoint.members.clone(), + }); + } + module.facts.0.agent_runtime.agents.clear(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .expect("resume verification"); + + assert!(response.units.is_empty()); + assert!(!response.application.pending); + assert!(executor.0.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn removing_an_application_allows_a_saved_agent_that_now_reads_standard() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + let agent = module + .facts + .0 + .agent_runtime + .agents + .values_mut() + .next() + .expect("saved Agent"); + agent.definition.global.reads_standard = true; + agent.global.reads_standard = true; + agent.global.standard_path = Some(TEST_SKILL_ROOT.to_string()); + agent.global.standard_presence = Some(DirectoryPresenceState::Present); + agent.global.read_paths.push(TEST_SKILL_ROOT.to_string()); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let options = module.agent_options(draft.context.clone()).await.unwrap(); + assert_eq!( + options.selection.unavailable_explicit_agents[0].reason, + UnavailableAgentSelectionReason::OptionUnavailable + ); let (plan, response) = applied_result(&module, &executor, draft).await; + assert!(response.application.ordered_libraries.is_empty()); + assert!(response.application.selected_agent_ids.is_empty()); assert!(plan.units[0] .additional_entries .iter() - .all(|entry| entry.action == PreparedEntryAction::Keep)); + .any(|entry| entry.action == PreparedEntryAction::Remove)); assert_eq!( - response.application.selected_agent_ids, - vec![AgentId::parse("private-agent").unwrap()] + plan.units[0] + .primary_entry + .as_ref() + .map(|entry| &entry.action), + Some(&PreparedEntryAction::Keep) ); } #[tokio::test] - async fn applying_a_library_identifies_an_unsupported_agent_entry() { - let (module, _executor, draft) = application_fixture(TargetEntryKind::File, None); - - let error = module.preview(draft).await.unwrap_err(); - - assert!(matches!( - error, - AppError::SkillPlacementTargetConflict { - skill_name, - agent_ids, - target_path, - target_kind, - } if skill_name == "demo" - && agent_ids == vec![AgentId::parse("private-agent").unwrap()] + async fn removing_a_saved_agent_cleans_its_declared_legacy_path_after_it_reads_standard() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + let agent = module + .facts + .0 + .agent_runtime + .agents + .values_mut() + .next() + .unwrap(); + agent.definition.global.reads_standard = true; + agent.definition.global.private_path = None; + agent.definition.legacy_paths.push(LegacyPath { + scope: LegacyPathScope::Global, + path: PathSpec::home(".legacy-agent/skills"), + behavior: LegacyPathBehavior::OfferMigration, + migration_target: LegacyMigrationTarget::StandardCanonical, + }); + agent.global.reads_standard = true; + agent.global.standard_path = Some(TEST_SKILL_ROOT.to_string()); + agent.global.private_path = None; + agent.global.standard_presence = Some(DirectoryPresenceState::Present); + agent.global.private_presence = None; + agent.global.read_paths = vec![TEST_SKILL_ROOT.to_string()]; + agent.global.legacy_paths.push(ResolvedPathPresence { + path: Some(TEST_LEGACY_ROOT.to_string()), + presence: DirectoryPresenceState::Present, + }); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let (plan, _) = applied_result(&module, &executor, draft).await; + + let legacy_skill = locator(TEST_LEGACY_ROOT).join_child("demo"); + assert!(plan.units[0].additional_entries.iter().any(|entry| { + entry.destination == legacy_skill && entry.action == PreparedEntryAction::Remove + })); + } + + #[tokio::test] + async fn removing_an_application_drops_an_unknown_saved_agent_without_guessing_its_path() { + let (mut module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.ordered_library_ids.clear(); + draft.selected_agent_ids.clear(); + + let (plan, response) = applied_result(&module, &executor, draft).await; + + assert!(response.application.ordered_libraries.is_empty()); + assert!(response.application.selected_agent_ids.is_empty()); + assert!(plan.units[0].additional_entries.is_empty()); + assert_eq!( + plan.units[0] + .primary_entry + .as_ref() + .map(|entry| &entry.action), + Some(&PreparedEntryAction::Keep) + ); + } + + #[tokio::test] + async fn an_unknown_saved_agent_can_be_carried_forward_without_becoming_a_new_selection() { + let (mut module, _executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + + let preview = module + .preview(draft) + .await + .expect("an existing unavailable association can be retained"); + + assert_eq!(preview.target.selected_agent_ids.len(), 1); + assert_eq!( + preview.target.selected_agent_ids[0].as_str(), + "private-agent" + ); + } + + #[tokio::test] + async fn agent_options_expose_an_unknown_saved_association() { + let (mut module, _executor, draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + + let options = module.agent_options(draft.context).await.unwrap(); + + assert_eq!(options.selection.unavailable_explicit_agents.len(), 1); + assert_eq!( + options.selection.unavailable_explicit_agents[0].agent_id, + "private-agent" + ); + assert_eq!( + options.selection.unavailable_explicit_agents[0].reason, + crate::application::agent_selection::UnavailableAgentSelectionReason::DefinitionMissing + ); + } + + #[tokio::test] + async fn an_unknown_agent_cannot_be_added_as_a_new_association() { + let (mut module, _executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: LibraryId::parse("lib-one"), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![LibraryId::parse("lib-one")], + vec![LibraryId::parse("lib-one")], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.selected_agent_ids = vec![AgentId::parse("new-agent").unwrap()]; + + let error = module.preview(draft).await.unwrap_err(); + + assert_eq!( + error, + AppError::InvalidAgent { + agent: "new-agent".to_string() + } + ); + } + + #[tokio::test] + async fn applying_a_library_replaces_a_broken_agent_link() { + let (module, executor, draft) = + application_fixture(TargetEntryKind::BrokenLink, Some("/missing/direct-skill")); + + let (plan, _response) = applied_result(&module, &executor, draft).await; + + assert_eq!(plan.units.len(), 1); + assert!(matches!( + plan.units[0] + .additional_entries + .first() + .map(|entry| &entry.action), + Some(PreparedEntryAction::Link { target }) + if target.native_path == format!("{TEST_LIBRARY_ROOT}/demo") + )); + } + + #[tokio::test] + async fn applying_a_library_preserves_a_valid_direct_agent_link() { + let (module, executor, draft) = + application_fixture(TargetEntryKind::Symlink, Some("/direct-skill/demo")); + + let (plan, response) = applied_result(&module, &executor, draft).await; + + assert!(plan.units[0] + .additional_entries + .iter() + .all(|entry| entry.action == PreparedEntryAction::Keep)); + assert_eq!( + response.application.selected_agent_ids, + vec![AgentId::parse("private-agent").unwrap()] + ); + } + + #[tokio::test] + async fn applying_a_library_identifies_an_unsupported_agent_entry() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::File, None); + + let error = module.preview(draft).await.unwrap_err(); + + assert!(matches!( + error, + AppError::SkillPlacementTargetConflict { + skill_name, + agent_ids, + target_path, + target_kind, + } if skill_name == "demo" + && agent_ids == vec![AgentId::parse("private-agent").unwrap()] && target_path.ends_with("demo") && target_kind == crate::error::SkillPlacementTargetKind::File )); @@ -1943,9 +3316,10 @@ mod tests { id, name: name.to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }; - let (module, executor, draft, _) = application_fixture_with( + let (module, executor, draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::Symlink, Some("/libraries/first/skills/demo"), @@ -1990,7 +3364,7 @@ mod tests { #[tokio::test] async fn removing_a_broken_agent_library_link_is_visible_when_canonical_is_direct() { let library_id = LibraryId::parse("lib-one"); - let (module, _executor, mut draft, _) = application_fixture_with( + let (module, _executor, mut draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::BrokenLink, Some("/libraries/lib-one/skills/demo"), @@ -1998,6 +3372,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], vec![library_id], @@ -2014,7 +3389,7 @@ mod tests { #[tokio::test] async fn changing_only_library_agent_associations_reports_directory_changes() { let library_id = LibraryId::parse("lib-one"); - let (module, _executor, mut draft, _) = application_fixture_with( + let (module, _executor, mut draft, _, _) = application_fixture_with( TargetEntryKind::Directory, TargetEntryKind::Symlink, Some("/libraries/lib-one/skills/demo"), @@ -2022,6 +3397,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill("demo")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], vec![library_id.clone()], @@ -2037,19 +3413,846 @@ mod tests { assert_eq!(preview.changed_directory_skill_names, vec!["demo"]); } - #[test] - fn library_links_reject_non_native_storage_facts() { - let mut cross_storage = fact(TargetEntryKind::Missing, None); - cross_storage.storage_access = StorageAccess::CrossStorage; - assert!(matches!( - ensure_library_link_targets_supported(&[cross_storage]), - Err(AppError::CapabilityUnavailable { .. }) - )); - assert!( - ensure_library_link_targets_supported(&[fact(TargetEntryKind::Missing, None,)]).is_ok() + #[tokio::test] + async fn retired_checkpoint_member_is_recognized_for_link_removal_only() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, mut draft, _, _) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: Vec::new(), + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + member: skill("demo"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + vec![library_id], + Vec::new(), + ); + draft.selected_agent_ids.clear(); + + let (plan, _) = applied_result(&module, &executor, draft).await; + + assert_eq!(plan.units.len(), 1); + assert!(plan.units[0] + .additional_entries + .iter() + .any(|entry| entry.action == PreparedEntryAction::Remove)); + } + + #[tokio::test] + async fn failed_checkpoint_commit_is_completed_by_a_keep_only_resume() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Directory, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + Vec::new(), + vec![library_id.clone()], + ); + repository + .fail_next_checkpoint + .store(true, Ordering::SeqCst); + let preview = module.preview(draft.clone()).await.unwrap(); + + let error = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await + .unwrap_err(); + + assert!(matches!(error, AppError::Io { .. })); + let pending = repository.record.lock().unwrap().clone(); + assert!(pending.pending.is_some()); + assert!(pending.checkpoint.members.is_empty()); + + module + .retry_pending(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + let completed = repository.record.lock().unwrap().clone(); + assert!(completed.pending.is_none()); + assert_eq!( + completed.checkpoint.members, + vec![LibraryMemberIdentity { + library_id, + member_name: "demo".to_string(), + }] + ); + let recorded = executor.0.lock().unwrap(); + let plan = recorded.as_ref().unwrap(); + assert!(plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .all(|entry| entry.action == PreparedEntryAction::Keep)); + } + + #[tokio::test] + async fn recovery_required_execution_remains_visible_after_reload() { + let (module, _executor, draft) = application_fixture(TargetEntryKind::Missing, None); + let LibraryApplicationModule { + repository, + facts, + targets, + .. + } = module; + let module = LibraryApplicationModule::with_recovery_status( + repository, + facts, + targets, + RecoveryExecutor, + Arc::new(FixedRecoveryStatus(ScopeRecoveryState::Required)), + ); + let preview = module.preview(draft.clone()).await.unwrap(); + + let response = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + response.application.sync_state, + LibraryApplicationSyncState::RecoveryRequired + ); + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::RecoveryRequired ); } + #[tokio::test] + async fn inaccessible_membership_drift_is_persisted_as_unverified() { + let library_id = LibraryId::parse("lib-one"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "demo", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Unverified, + ) + .await + .unwrap(); + + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::Unverified + ); + } + + #[tokio::test] + async fn inaccessible_synced_scope_does_not_create_an_actionable_pending() { + let library_id = LibraryId::parse("lib-one"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Unverified, + ) + .await + .unwrap(); + + assert!(repository.record.lock().unwrap().pending.is_none()); + assert_eq!( + module.read(draft.context).await.unwrap().sync_state, + LibraryApplicationSyncState::Synced + ); + } + + #[tokio::test] + async fn membership_resume_rebuilds_pending_from_persisted_drift() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/demo"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "demo", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + let record = repository.record.lock().unwrap().clone(); + assert!(record.pending.is_none()); + assert!(record.checkpoint.members.is_empty()); + let recorded = executor.0.lock().unwrap(); + let plan = recorded.as_ref().unwrap(); + assert!(plan + .units + .iter() + .flat_map(|unit| unit.primary_entry.iter().chain(&unit.additional_entries)) + .any(|entry| entry.action == PreparedEntryAction::Remove)); + } + + #[tokio::test] + async fn projected_catalog_reports_a_high_priority_member_switch_before_commit() { + let high = LibraryId::parse("high"); + let low = LibraryId::parse("low"); + let (module, _executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Symlink, + TargetEntryKind::Symlink, + Some("/libraries/low/skills/demo"), + vec![ + SkillLibraryRecord { + id: high.clone(), + name: "High".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }, + SkillLibraryRecord { + id: low.clone(), + name: "Low".to_string(), + skills: vec![skill("demo")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }, + ], + vec![high.clone(), low.clone()], + vec![high.clone(), low], + ); + let mut projected = repository.catalog.lock().unwrap().clone(); + crate::application::library_membership::apply_membership_change( + &mut projected, + &high, + "demo", + crate::application::library_membership::MembershipChange::Upsert(skill("demo")), + ) + .unwrap(); + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .unwrap(); + + assert_eq!(plan.preview.unwrap().switched_skill_names, vec!["demo"]); + } + + #[test] + fn library_links_reject_non_native_storage_facts() { + let mut cross_storage = fact(TargetEntryKind::Missing, None); + cross_storage.storage_access = StorageAccess::CrossStorage; + assert!(matches!( + ensure_library_link_targets_supported(&[cross_storage]), + Err(AppError::CapabilityUnavailable { .. }) + )); + assert!( + ensure_library_link_targets_supported(&[fact(TargetEntryKind::Missing, None,)]).is_ok() + ); + } + + #[test] + fn pending_merge_preserves_identity_and_expands_recognized_members() { + let original = PendingReconciliation { + reconciliation_id: "reconciliation-1".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ApplicationChanged], + before_application: LibraryApplicationState::default(), + target_application: LibraryApplicationState::default(), + recognized_members: vec![member("library-a", "alpha")], + affected_members: Vec::new(), + target_members: vec![member("library-a", "alpha")], + }; + let latest_target = LibraryApplicationState { + ordered_library_ids: vec![LibraryId::parse("library-b")], + selected_agent_ids: Vec::new(), + }; + + let merged = original.merged_with( + vec![ReconciliationReason::MembershipChanged], + latest_target.clone(), + vec![member("library-a", "beta")], + vec![member("library-b", "gamma")], + ); + + assert_eq!(merged.reconciliation_id, "reconciliation-1"); + assert_eq!( + merged.reasons, + vec![ + ReconciliationReason::ApplicationChanged, + ReconciliationReason::MembershipChanged, + ] + ); + assert_eq!( + merged.recognized_members, + vec![ + member("library-a", "alpha"), + member("library-a", "beta"), + member("library-b", "gamma"), + ] + ); + assert_eq!(merged.target_application, latest_target); + assert_eq!(merged.target_members, vec![member("library-b", "gamma")]); + } + + #[test] + fn membership_reconciliation_only_targets_changed_skill_directories() { + let checkpoint = ReconciliationCheckpoint { + members: vec![member("lib-one", "alpha"), member("lib-one", "shared")], + }; + let target = vec![ + member("lib-one", "alpha"), + member("lib-two", "shared"), + member("lib-one", "beta"), + ]; + + let changed = membership_changed_skill_directories(&checkpoint, None, &target).unwrap(); + + assert_eq!( + changed, + BTreeSet::from([ + SkillDirectoryName::try_from("beta").unwrap(), + SkillDirectoryName::try_from("shared").unwrap(), + ]) + ); + } + + #[tokio::test] + async fn projected_member_addition_does_not_repair_an_unrelated_missing_skill() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + let projected = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .expect("plan member propagation"); + let preview = plan.preview.expect("member preview"); + + assert_eq!(preview.added_skill_names, vec!["beta"]); + assert_eq!(plan.entries.len(), 1); + } + + #[tokio::test] + async fn reapply_with_membership_drift_retains_its_full_scope_after_failure() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, mut draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + module.facts.0.agent_runtime.agents.clear(); + draft.selected_agent_ids.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + repository.catalog.lock().unwrap().libraries[0] + .skills + .push(skill("beta")); + repository + .fail_next_checkpoint + .store(true, Ordering::SeqCst); + let preview = module.preview(draft.clone()).await.unwrap(); + + let result = module + .apply( + ApplyLibraryApplicationRequest { + draft: draft.clone(), + expected_token: preview.token, + }, + CancellationSignal::default(), + ) + .await; + assert!(matches!(result, Err(AppError::Io { .. }))); + + let resumed = module + .retry_pending(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert_eq!( + resumed + .units + .iter() + .map(|unit| unit.skill_name.as_str()) + .collect::>(), + vec!["alpha", "beta"] + ); + assert!(!resumed.application.pending); + } + + #[tokio::test] + async fn projected_member_addition_preserves_a_confirmed_reapply_scope() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + { + let mut record = repository.record.lock().unwrap(); + record.current.selected_agent_ids.clear(); + record.pending = Some(PendingReconciliation { + reconciliation_id: "reapply-alpha".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::ReapplyRequested], + before_application: record.current.clone(), + target_application: record.current.clone(), + recognized_members: record.checkpoint.members.clone(), + affected_members: Vec::new(), + target_members: record.checkpoint.members.clone(), + }); + } + let projected = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + let plan = module + .plan_resume_with_catalog(draft.context, projected) + .await + .expect("plan membership and confirmed reapply"); + + assert_eq!(plan.entries.len(), 2); + assert_eq!( + plan.preview.unwrap().changed_directory_skill_names, + vec!["alpha", "beta"] + ); + } + + #[test] + fn reconciliation_scope_is_checkpoint_pending_and_desired_union() { + let checkpoint = ReconciliationCheckpoint { + members: vec![member("library-a", "old")], + }; + let pending = PendingReconciliation { + reconciliation_id: "reconciliation-1".to_string(), + attention: ReconciliationAttention::Pending, + reasons: vec![ReconciliationReason::MembershipChanged], + before_application: LibraryApplicationState::default(), + target_application: LibraryApplicationState::default(), + recognized_members: vec![member("library-a", "pending")], + affected_members: vec![member("library-a", "pending")], + target_members: Vec::new(), + }; + + assert_eq!( + reconciliation_members( + &checkpoint, + Some(&pending), + &[member("library-b", "desired")], + ), + vec![ + member("library-a", "old"), + member("library-a", "pending"), + member("library-b", "desired"), + ] + ); + } + + #[tokio::test] + async fn membership_resume_cleans_a_published_member_retired_before_checkpoint() { + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Symlink, + Some("/libraries/lib-one/skills/beta"), + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + repository.catalog.lock().unwrap().libraries[0] + .skills + .extend([skill("beta"), skill("gamma")]); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + let plan = executor.0.lock().unwrap(); + let beta_path = locator(TEST_AGENT_ROOT).join_child("beta"); + assert!( + plan.as_ref().unwrap().units.iter().any(|unit| { + unit.skill_name == "beta" + && unit.additional_entries.iter().any(|entry| { + entry.destination == beta_path + && entry.action == PreparedEntryAction::Remove + }) + }), + "the published beta link must be removed before its pending references are released" + ); + } + + #[tokio::test] + async fn net_zero_membership_resume_preserves_an_unrelated_missing_link() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + repository.catalog.lock().unwrap().libraries[0] + .skills + .push(skill("beta")); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + assert!(response.units.iter().all(|unit| unit.skill_name != "alpha")); + } + + fn member(library_id: &str, member_name: &str) -> LibraryMemberIdentity { + LibraryMemberIdentity { + library_id: LibraryId::parse(library_id), + member_name: member_name.to_string(), + } + } + + #[tokio::test] + async fn membership_resume_remembers_a_member_removed_and_readded_before_completion() { + let library_id = LibraryId::parse("lib-one"); + let (mut module, _, draft, _, repository) = application_fixture_with( + TargetEntryKind::Missing, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha"), skill("beta")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id.clone()], + ); + module.facts.0.agent_runtime.agents.clear(); + repository + .record + .lock() + .unwrap() + .current + .selected_agent_ids + .clear(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retire-beta", + ), + retired_at: "2026-09-07T00:00:00Z".to_string(), + }, + ) + .unwrap(); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + crate::application::library_membership::apply_membership_change( + &mut repository.catalog.lock().unwrap(), + &library_id, + "beta", + crate::application::library_membership::MembershipChange::Upsert(skill("beta")), + ) + .unwrap(); + module + .record_reconciliation_attention( + draft.context.clone(), + ReconciliationAttention::Pending, + ) + .await + .unwrap(); + + let response = module + .resume(draft.context, CancellationSignal::default()) + .await + .unwrap(); + + assert!(!response.application.pending); + assert_eq!( + response + .units + .iter() + .map(|unit| unit.skill_name.as_str()) + .collect::>(), + vec!["beta"] + ); + } + + #[tokio::test] + async fn synced_scope_background_resume_does_not_access_the_filesystem() { + struct UnavailableFacts; + + impl ScopePlanningSnapshotSource for UnavailableFacts { + fn snapshot<'a>( + &'a self, + _context: &'a SkillLocationRef, + ) -> InstallFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleEnvironment) }) + } + } + + let library_id = LibraryId::parse("lib-one"); + let (module, executor, draft, _, repository) = application_fixture_with( + TargetEntryKind::Directory, + TargetEntryKind::Missing, + None, + vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: vec![skill("alpha")], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + vec![library_id.clone()], + vec![library_id], + ); + let module = LibraryApplicationModule::new( + repository.clone(), + UnavailableFacts, + module.targets, + executor.clone(), + ); + + let plan = module.plan_resume(draft.context.clone()).await.unwrap(); + assert!(plan.entries.is_empty()); + assert!(plan.preview.is_none()); + let response = module + .resume(draft.context.clone(), CancellationSignal::default()) + .await + .unwrap(); + assert_eq!( + response.application.sync_state, + LibraryApplicationSyncState::Synced + ); + assert!(response.units.is_empty()); + assert!(executor.0.lock().unwrap().is_none()); + + let mut projected = repository.catalog.lock().unwrap().clone(); + assert!(module + .plan_resume_with_catalog(draft.context.clone(), projected.clone()) + .await + .unwrap() + .entries + .is_empty()); + projected.libraries[0].skills.push(skill("beta")); + assert!(matches!( + module + .plan_resume_with_catalog(draft.context, projected) + .await, + Err(AppError::StaleEnvironment) + )); + } + fn skill(name: &str) -> LibrarySkillRecord { LibrarySkillRecord { name: name.to_string(), diff --git a/src-tauri/src/application/library_candidates.rs b/src-tauri/src/application/library_candidates.rs index e514dbfd..ccdbb899 100644 --- a/src-tauri/src/application/library_candidates.rs +++ b/src-tauri/src/application/library_candidates.rs @@ -4,7 +4,7 @@ use std::pin::Pin; use std::sync::Arc; use crate::application::installed_skill_resolver::SkillDirectoryName; -use crate::application::library_application::LibraryApplicationRepository; +use crate::application::library_application::{LibraryApplicationBackend, LibraryMemberIdentity}; use crate::application::skill_libraries::{LibraryCatalog, LibraryId}; use crate::core::agent_definition::AgentId; use crate::environment::planning::{ResolvedTargetFact, TargetFactResolver}; @@ -78,27 +78,29 @@ pub(crate) enum LibraryCandidateError { UnknownLibrary { library_id: LibraryId, }, + UnknownCatalogMember { + library_id: LibraryId, + member_name: String, + }, DuplicateOrderedCandidate, OrderedCandidateNotRecognized, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub(crate) struct LibraryCatalogMember { - pub(crate) library_id: LibraryId, - pub(crate) member_name: String, -} +pub(crate) type LibraryCatalogMember = LibraryMemberIdentity; #[derive(Debug, Clone)] pub(crate) struct LibraryCatalogMemberIndex { library_ids: BTreeSet, - by_directory_name: BTreeMap>, + active_by_directory_name: BTreeMap>, + all_by_identity: BTreeMap, } impl LibraryCatalogMemberIndex { pub(crate) fn build(catalog: &LibraryCatalog) -> Result { let mut library_ids = BTreeSet::new(); - let mut by_directory_name = + let mut active_by_directory_name = BTreeMap::>::new(); + let mut all_by_identity = BTreeMap::new(); for library in &catalog.libraries { library_ids.insert(library.id.clone()); let mut members_in_library = BTreeMap::::new(); @@ -119,21 +121,54 @@ impl LibraryCatalogMemberIndex { duplicate_member_name: member.name.clone(), }); } - by_directory_name + let identity = LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }; + all_by_identity.insert(identity.clone(), directory_name.clone()); + active_by_directory_name .entry(directory_name) .or_default() - .push(LibraryCatalogMember { + .push(identity); + } + for retired in &library.retired_skills { + let member = &retired.member; + let directory_name = + SkillDirectoryName::try_from(member.name.as_str()).map_err(|_| { + LibraryCandidateError::InvalidCatalogMemberName { + library_id: library.id.clone(), + member_name: member.name.clone(), + } + })?; + if let Some(first_member_name) = + members_in_library.insert(directory_name.clone(), member.name.clone()) + { + return Err(LibraryCandidateError::DuplicateCatalogMemberDirectory { library_id: library.id.clone(), - member_name: member.name.clone(), + first_member_name, + duplicate_member_name: member.name.clone(), }); + } + all_by_identity.insert( + LibraryMemberIdentity { + library_id: library.id.clone(), + member_name: member.name.clone(), + }, + directory_name, + ); } } Ok(Self { library_ids, - by_directory_name, + active_by_directory_name, + all_by_identity, }) } + pub(crate) fn contains_identity(&self, member: &LibraryMemberIdentity) -> bool { + self.all_by_identity.contains_key(member) + } + pub(crate) fn members_for( &self, ordered_library_ids: &[LibraryId], @@ -146,7 +181,7 @@ impl LibraryCatalogMemberIndex { library_id: library_id.clone(), }); } - for (directory_name, members) in &self.by_directory_name { + for (directory_name, members) in &self.active_by_directory_name { if let Some(member) = members .iter() .find(|member| &member.library_id == library_id) @@ -160,6 +195,27 @@ impl LibraryCatalogMemberIndex { } Ok(grouped) } + + pub(crate) fn recognized_members( + &self, + members: &[LibraryMemberIdentity], + ) -> Result>, LibraryCandidateError> + { + let mut grouped = BTreeMap::>::new(); + for member in members { + let directory_name = self.all_by_identity.get(member).ok_or_else(|| { + LibraryCandidateError::UnknownCatalogMember { + library_id: member.library_id.clone(), + member_name: member.member_name.clone(), + } + })?; + grouped + .entry(directory_name.clone()) + .or_default() + .push(member.clone()); + } + Ok(grouped) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -309,12 +365,12 @@ pub(crate) trait LibraryCandidateSource: Send + Sync { } pub(crate) struct RepositoryLibraryCandidateSource { - repository: Arc, + repository: Arc, targets: Arc, } impl RepositoryLibraryCandidateSource { - pub(crate) fn new(repository: Arc, targets: T) -> Self + pub(crate) fn new(repository: Arc, targets: T) -> Self where T: TargetFactResolver + 'static, { @@ -349,7 +405,7 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { return Ok(Vec::new()); } let record = self.repository.load_application(context).await?; - if record.pending_operation.is_some() { + if record.pending.is_some() { return Err(AppError::MutationBusy); } let catalog = self.repository.load_catalog(context).await?; @@ -360,6 +416,16 @@ impl LibraryCandidateSource for RepositoryLibraryCandidateSource { let grouped = index .members_for(&record.current.ordered_library_ids) .map_err(candidate_configuration_error)?; + let desired_members = grouped + .values() + .flatten() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + if record.checkpoint.members != desired_members { + return Err(AppError::MutationBusy); + } let member_groups = skills .iter() .map(|skill| grouped.get(skill).cloned().unwrap_or_default()) @@ -402,7 +468,7 @@ pub(crate) struct ResolvedLibraryCandidateIndex { impl ResolvedLibraryCandidateIndex { pub(crate) async fn load( - repository: &dyn LibraryApplicationRepository, + repository: &dyn LibraryApplicationBackend, targets: &T, context: &SkillLocationRef, members: &[LibraryCatalogMember], @@ -488,11 +554,13 @@ mod tests { use super::*; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_application::{ - LibraryApplicationFuture, LibraryApplicationRecord, LibraryApplicationRepository, - LibraryApplicationState, PendingLibraryApplication, + ApplicationInventory, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResources, LibraryApplicationState, + PendingReconciliation, VersionedApplicationRecord, }; use crate::application::skill_libraries::{ - LibraryCatalog, LibraryId, LibrarySkillRecord, SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, + LibraryCatalog, LibraryId, LibrarySkillRecord, RetiredLibrarySkillRecord, + SkillLibraryRecord, LIBRARY_SCHEMA_VERSION, }; use crate::core::agent_definition::AgentId; use crate::environment::planning::{RuntimeTargetFactResolver, TargetFactFuture}; @@ -637,6 +705,7 @@ mod tests { id: library_id.clone(), name: "Library One".to_string(), skills: vec![skill_record("CE:Review"), skill_record("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -654,6 +723,55 @@ mod tests { )); } + #[test] + fn retired_members_are_recognized_but_never_ordered() { + let library_id = LibraryId::parse("library-one"); + let retired = LibraryMemberIdentity { + library_id: library_id.clone(), + member_name: "demo".to_string(), + }; + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id.clone(), + name: "Library One".to_string(), + skills: Vec::new(), + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + member: LibrarySkillRecord { + name: "demo".to_string(), + description: String::new(), + source_record: serde_json::json!({}), + content_manifest_hash: "manifest".to_string(), + updated_at: None, + extra: serde_json::Map::new(), + }, + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + let index = LibraryCatalogMemberIndex::build(&catalog).unwrap(); + + assert!(index + .members_for(std::slice::from_ref(&library_id)) + .unwrap() + .is_empty()); + let recognized = index + .recognized_members(std::slice::from_ref(&retired)) + .unwrap(); + assert_eq!( + recognized + .get(&SkillDirectoryName::try_from("demo").unwrap()) + .unwrap(), + &[retired] + ); + } + struct MemoryRepository { record: LibraryApplicationRecord, catalog: LibraryCatalog, @@ -661,22 +779,53 @@ mod tests { locator_requests: Mutex>, } - impl LibraryApplicationRepository for MemoryRepository { + impl ApplicationRegistry for MemoryRepository { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, - _record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { - Box::pin(async { Ok(()) }) + observed: &'a VersionedApplicationRecord, + record: &'a LibraryApplicationRecord, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) } + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + self.record.clone(), + )], + problems: Vec::new(), + complete: true, + }) + }) + } + } + + impl LibraryApplicationResources for MemoryRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -714,9 +863,9 @@ mod tests { Box::pin(async move { Ok(self.catalog.clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async { Ok(()) }) } @@ -813,6 +962,7 @@ mod tests { id, name: name.to_string(), skills: vec![skill_record(name)], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }; let repository = Arc::new(MemoryRepository { @@ -823,7 +973,19 @@ mod tests { ordered_library_ids: vec![first_id.clone(), second_id.clone()], selected_agent_ids: vec![agent("cursor"), agent("codex")], }, - pending_operation: None, + checkpoint: crate::application::library_application::ReconciliationCheckpoint { + members: vec![ + LibraryMemberIdentity { + library_id: first_id.clone(), + member_name: "CE:Review".to_string(), + }, + LibraryMemberIdentity { + library_id: second_id.clone(), + member_name: "ce-review".to_string(), + }, + ], + }, + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -899,7 +1061,16 @@ mod tests { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), }, - pending_operation: None, + checkpoint: crate::application::library_application::ReconciliationCheckpoint { + members: ["alpha", "beta"] + .into_iter() + .map(|member_name| LibraryMemberIdentity { + library_id: library_id.clone(), + member_name: member_name.to_string(), + }) + .collect(), + }, + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -917,6 +1088,7 @@ mod tests { extra: serde_json::Map::new(), }) .collect(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -966,7 +1138,8 @@ mod tests { ordered_library_ids: vec![library_id.clone()], selected_agent_ids: Vec::new(), }, - pending_operation: None, + checkpoint: Default::default(), + pending: None, }, catalog: LibraryCatalog { schema_version: LIBRARY_SCHEMA_VERSION, @@ -974,6 +1147,7 @@ mod tests { id: library_id, name: "Library One".to_string(), skills: vec![skill_record("CE:Review"), skill_record("ce-review")], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -1008,11 +1182,18 @@ mod tests { schema_version: crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION, current: current.clone(), - pending_operation: Some(PendingLibraryApplication { - operation_id: "operation-pending".to_string(), + checkpoint: Default::default(), + pending: Some(PendingReconciliation { + reconciliation_id: "reconciliation-pending".to_string(), + attention: crate::application::library_application::ReconciliationAttention::Pending, + reasons: vec![ + crate::application::library_application::ReconciliationReason::ApplicationChanged, + ], before_application: current.clone(), target_application: current, - preview_fingerprint: "preview-pending".to_string(), + recognized_members: Vec::new(), + affected_members: Vec::new(), + target_members: Vec::new(), }), }, catalog: LibraryCatalog { @@ -1032,6 +1213,52 @@ mod tests { assert!(matches!(result, Err(AppError::MutationBusy))); } + #[tokio::test] + async fn repository_source_rejects_an_unreconciled_checkpoint() { + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let library_id = LibraryId::parse("library-one"); + let repository = Arc::new(MemoryRepository { + record: LibraryApplicationRecord { + current: LibraryApplicationState { + ordered_library_ids: vec![library_id.clone()], + selected_agent_ids: Vec::new(), + }, + ..LibraryApplicationRecord::empty() + }, + catalog: LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: library_id, + name: "Library One".to_string(), + skills: vec![LibrarySkillRecord { + name: "demo".to_string(), + description: String::new(), + source_record: serde_json::json!({}), + content_manifest_hash: "manifest".to_string(), + updated_at: None, + extra: serde_json::Map::new(), + }], + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }, + locator_root: None, + locator_requests: Mutex::new(Vec::new()), + }); + let source = RepositoryLibraryCandidateSource::new(repository, targets()); + + assert!(matches!( + source + .load_candidates(&context, &SkillDirectoryName::try_from("demo").unwrap()) + .await, + Err(AppError::MutationBusy) + )); + } + #[tokio::test] async fn empty_source_returns_deterministic_empty_evidence() { let source = EmptyLibraryCandidateSource; diff --git a/src-tauri/src/application/recovery.rs b/src-tauri/src/application/recovery.rs index 0a0db374..059fcf12 100644 --- a/src-tauri/src/application/recovery.rs +++ b/src-tauri/src/application/recovery.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use serde::Serialize; use specta::Type; +use crate::application::library_application::{ + LibraryApplicationFuture, LibraryApplicationRecoveryStatus, ScopeRecoveryState, +}; use crate::application::mutation::result::ErrorReport; use crate::environment::recovery::{ RecoveryResourcePath, RecoveryResourcePathKind, RecoverySubject, @@ -145,6 +148,48 @@ where } } +impl LibraryApplicationRecoveryStatus for RecoveryService +where + C: RecoveryConsistencyChecker, +{ + fn status<'a>( + &'a self, + context: &'a crate::environment::types::SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let mut result = ScopeRecoveryState::Clear; + for recovery in self.list().await? { + let same_environment = recovery.environment.as_ref().is_some_and(|environment| { + crate::environment::types::same_environment_identity( + environment, + &context.environment, + ) + }); + let same_scope = recovery.subject.as_ref().is_some_and(|subject| { + crate::environment::types::same_environment_identity( + &subject.context.environment, + &context.environment, + ) && subject.context.scope == context.scope + }); + if !(same_scope || same_environment && recovery.subject.is_none()) { + continue; + } + match recovery.state { + RecoveryResourceState::NeedsAttention | RecoveryResourceState::Invalid => { + return Ok(ScopeRecoveryState::Required); + } + RecoveryResourceState::EnvironmentUnavailable => { + result = ScopeRecoveryState::Unverified; + } + RecoveryResourceState::ConsistentCanCleanup + | RecoveryResourceState::Missing => {} + } + } + Ok(result) + }) + } +} + fn status_from_assessment( assessment: crate::storage::recovery_repository::RecoveryAssessment, ) -> RecoveryResourceStatus { @@ -261,6 +306,15 @@ mod tests { let attention = service.status(&marker.resource_id).await.expect("status"); assert_eq!(attention.state, RecoveryResourceState::NeedsAttention); assert!(!attention.revision.is_empty()); + assert_eq!( + LibraryApplicationRecoveryStatus::status( + &service, + &marker.subject.as_ref().unwrap().context, + ) + .await + .expect("application recovery status"), + ScopeRecoveryState::Required + ); service .repository() @@ -273,6 +327,15 @@ mod tests { consistent.state, RecoveryResourceState::ConsistentCanCleanup ); + assert_eq!( + LibraryApplicationRecoveryStatus::status( + &service, + &marker.subject.as_ref().unwrap().context, + ) + .await + .expect("application recovery status"), + ScopeRecoveryState::Clear + ); service .confirm_resolved(&marker.resource_id, &consistent.revision) .await diff --git a/src-tauri/src/application/scope_skill_planning/mod.rs b/src-tauri/src/application/scope_skill_planning/mod.rs index effc24c1..62e82e9b 100644 --- a/src-tauri/src/application/scope_skill_planning/mod.rs +++ b/src-tauri/src/application/scope_skill_planning/mod.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; mod election; @@ -6,9 +6,7 @@ use crate::application::agent_selection::{ AgentInstallOptionKind, AgentSelectionCatalog, DirectoryPlacementId, }; use crate::application::installed_skill_resolver::SkillDirectoryName; -use crate::application::library_agent_placements::{ - LibraryAgentPlacementError, LibraryAgentPlacementMap, -}; +use crate::application::library_agent_placements::LibraryAgentPlacementMap; use crate::application::library_candidates::{LibraryCandidateError, LibraryCandidateSet}; use crate::application::mutation::plan::{ stable_digest, ExpectedTargetEntry, PreparedEntryAction, PreparedEntryMutation, @@ -108,6 +106,7 @@ pub(crate) struct LibrarySkillChangeRequest<'a> { pub(crate) placements: ScopeSkillPlacementSet, pub(crate) before: LibraryElectionState<'a>, pub(crate) after: LibraryElectionState<'a>, + pub(crate) before_applied: bool, pub(crate) legacy: Vec, } @@ -119,7 +118,6 @@ pub(crate) enum ScopeSkillPlanningError { CatalogPlacementMismatch(DirectoryPlacementId), LibraryCandidate(LibraryCandidateError), InvalidInput(SkillDirectoryInputError), - LibraryAgentPlacement(LibraryAgentPlacementError), ConflictingDirectContent { target_path: String, }, @@ -156,16 +154,6 @@ impl ScopeSkillPlanningError { Self::InvalidInput(error) => AppError::ConfigurationCorrupted { message: format!("Scope Skill planning input is inconsistent: {error:?}"), }, - Self::LibraryAgentPlacement(LibraryAgentPlacementError::UnknownAgent(agent)) => { - AppError::InvalidAgent { - agent: agent.as_str().to_string(), - } - } - Self::LibraryAgentPlacement(LibraryAgentPlacementError::PartialSelection(_)) => { - AppError::AgentSelectionInvalid { - reason: crate::error::AgentSelectionInvalidReason::OptionUnavailable, - } - } Self::ConflictingDirectContent { target_path } | Self::ConflictingDirectMaterialization { target_path } => AppError::Validation { field: Some("skillName".to_string()), @@ -367,8 +355,7 @@ impl ScopeSkillPlanner { request.placements, request.libraries, request.libraries, - request.direct_changes, - Vec::new(), + ScopeSkillChange::Direct(request.direct_changes), ) } @@ -381,10 +368,39 @@ impl ScopeSkillPlanner { request.placements, request.before, request.after, - BTreeMap::new(), - request.legacy, + ScopeSkillChange::Library { + before_applied: request.before_applied, + legacy: request.legacy, + }, ) } + + pub(crate) fn conflicting_mutations<'a>( + entries: impl IntoIterator, + ) -> BTreeSet { + let mut expected = BTreeMap::::new(); + let mut conflicts = BTreeSet::new(); + for (key, action) in entries { + match expected.get(key) { + Some(existing) if existing != action => { + conflicts.insert(key.clone()); + } + Some(_) => {} + None => { + expected.insert(key.clone(), action.clone()); + } + } + } + conflicts + } +} + +enum ScopeSkillChange { + Direct(BTreeMap), + Library { + before_applied: bool, + legacy: Vec, + }, } fn plan( @@ -393,9 +409,15 @@ fn plan( placements: ScopeSkillPlacementSet, before: LibraryElectionState<'_>, after: LibraryElectionState<'_>, - direct_changes: BTreeMap, - legacy: Vec, + change: ScopeSkillChange, ) -> Result { + let (before_applied, direct_changes, legacy) = match change { + ScopeSkillChange::Direct(changes) => (None, changes, Vec::new()), + ScopeSkillChange::Library { + before_applied, + legacy, + } => (Some(before_applied), BTreeMap::new(), legacy), + }; if catalog.context() != &placements.context { return Err(ScopeSkillPlanningError::ScopeMismatch); } @@ -415,12 +437,8 @@ fn plan( } } let library_placements = LibraryAgentPlacementMap::from_catalog(catalog); - let before_library = library_placements - .placements_for(before.selected_agent_ids) - .map_err(ScopeSkillPlanningError::LibraryAgentPlacement)?; - let after_library = library_placements - .placements_for(after.selected_agent_ids) - .map_err(ScopeSkillPlanningError::LibraryAgentPlacement)?; + let before_library = library_placements.placements_for_saved(before.selected_agent_ids); + let after_library = library_placements.placements_for_saved(after.selected_agent_ids); validate_prepared_direct_changes(&placements.resolved, &direct_changes)?; let mut recognized = before.candidates.recognized().to_vec(); @@ -431,7 +449,8 @@ fn plan( } let candidates = LibraryCandidateSet::new(recognized, after.candidates.ordered().to_vec()) .map_err(ScopeSkillPlanningError::LibraryCandidate)?; - let before_has_library = !before.candidates.ordered().is_empty(); + let before_has_library = + before_applied.unwrap_or_else(|| !before.candidates.ordered().is_empty()); let after_has_library = !after.candidates.ordered().is_empty(); let prepared_keys = direct_changes .iter() @@ -1253,6 +1272,7 @@ mod tests { candidates: &target_candidates, selected_agent_ids: &[], }, + before_applied: true, legacy: vec![LegacyLibraryPlacement { fact: fact( "legacy-demo", @@ -1271,4 +1291,50 @@ mod tests { .expect("legacy directory remains in the plan"); assert_eq!(legacy.action(), &PreparedEntryAction::Keep); } + + #[test] + fn cross_scope_conflicts_are_grouped_by_physical_target_and_elected_version() { + let first = plan_with_library("shared", "library-a"); + let same = plan_with_library("shared", "library-a"); + let different = plan_with_library("shared", "library-b"); + + assert!(ScopeSkillPlanner::conflicting_mutations( + [&first, &same] + .into_iter() + .flat_map(|plan| plan.directories().iter()) + .map(|directory| (&directory.fact.key, &directory.action)) + ) + .is_empty()); + assert_eq!( + ScopeSkillPlanner::conflicting_mutations( + [&first, &different] + .into_iter() + .flat_map(|plan| plan.directories().iter()) + .map(|directory| (&directory.fact.key, &directory.action)) + ), + std::collections::BTreeSet::from([key("shared")]) + ); + } + + fn plan_with_library(target_key: &str, library_id: &str) -> ScopeSkillPlan { + let candidate = LibraryVersionCandidate::new( + LibraryId::parse(library_id), + "demo", + locator(&format!("/libraries/{library_id}/skills/demo")), + ); + ScopeSkillPlan { + directories: vec![ScopePlannedDirectory { + fact: fact(target_key, TargetEntryKind::Missing, None), + placements: vec![DirectoryPlacementRef::Catalog( + DirectoryPlacementId::Standard, + )], + observed: ObservedVersion::Unknown, + elected: ElectedVersion::Library(candidate.clone()), + action: PreparedEntryAction::Link { + target: candidate.locator().clone(), + }, + readers: Vec::new(), + }], + } + } } diff --git a/src-tauri/src/application/skill_read.rs b/src-tauri/src/application/skill_read.rs index 1def1e51..f58ec33b 100644 --- a/src-tauri/src/application/skill_read.rs +++ b/src-tauri/src/application/skill_read.rs @@ -356,6 +356,8 @@ pub fn project_skill_snapshot( ordered_libraries: Vec::new(), selected_agent_ids: Vec::new(), pending: false, + sync_state: + crate::application::library_application::LibraryApplicationSyncState::Synced, }, }) } diff --git a/src-tauri/src/environment/wsl/operations/library.rs b/src-tauri/src/environment/wsl/operations/library.rs index 79b10475..fd9f9163 100644 --- a/src-tauri/src/environment/wsl/operations/library.rs +++ b/src-tauri/src/environment/wsl/operations/library.rs @@ -15,6 +15,32 @@ pub struct LibraryCatalogSnapshot { } impl WslWorkspace { + pub(crate) async fn list_library_applications( + &self, + ) -> Result { + let response: environment_protocol::LibraryApplicationIndex = self + .request_worker_payload(environment_protocol::Message::ListLibraryApplications { + deadline_millis: LIBRARY_DEADLINE_MILLIS, + }) + .await?; + if response.project_ids.len() > environment_protocol::MAX_DIRECTORY_COUNT_LIMIT as usize + || response.problem_keys.len() + > environment_protocol::MAX_DIRECTORY_COUNT_LIMIT as usize + || response + .project_ids + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || response + .problem_keys + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || response.complete != response.problem_keys.is_empty() + { + return Err(protocol_error("LibraryApplicationIndex")); + } + Ok(response) + } + pub(crate) async fn read_library_catalog(&self) -> Result>, AppError> { let response: environment_protocol::LibraryCatalogResponse = self .request_worker_payload(environment_protocol::Message::ReadLibraryCatalog { diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index 90accfc1..6c968afa 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -243,11 +243,12 @@ impl RuntimeServiceGraph { agent_selection_targets.clone(), update_evidence.clone(), ); - let library_application = Arc::new(LibraryApplicationModule::new( + let library_application = Arc::new(LibraryApplicationModule::with_recovery_status( library_repository.clone(), agent_selection_facts.clone(), agent_selection_targets.clone(), execution.executor(wsl.clone(), Arc::new(agent_selection_facts.clone())), + Arc::new(execution.recovery_service()), )); let library_candidates: Arc = Arc::new(RepositoryLibraryCandidateSource::new( diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index 4932f9b8..39d1dae9 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -9,15 +9,17 @@ use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use crate::application::collection_records::CollectionRecordReader; use crate::application::installed_skill_resolver::InstalledSkillResolver; use crate::application::library_application::{ - library_usage_state, LibraryApplicationFuture, LibraryApplicationRecord, - LibraryApplicationRepository, LibraryUsageAccumulator, LIBRARY_APPLICATION_SCHEMA_VERSION, + library_usage_state, validate_application_record, ApplicationInventory, + ApplicationInventoryProblem, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResources, LibraryUsageAccumulator, + VersionedApplicationRecord, }; use crate::application::payload_session::{ PayloadLocalSource, PayloadSessionStorage, PayloadStorageKey, }; use crate::application::skill_libraries::{ validate_catalog, CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, - LibraryMemberMutation, LibraryUsage, LibraryUsageProjection, LibraryUsageProvider, + LibraryMemberMutation, LibraryUsage, LibraryUsageProvider, LibraryUsageSnapshot, LibraryUsageState, SkillLibraryRepository, }; use crate::application::skill_paths::{ResolvedSkillRoot, SkillPathObserver}; @@ -31,6 +33,7 @@ use crate::environment::types::{ }; use crate::environment::types::{SkillLocation, SkillLocationRef}; use crate::environment::wsl::operations::acquire::WslPayloadSessionStorage; +use crate::environment::wsl::operations::atomic_file::WslAtomicDocumentIo; use crate::environment::wsl::WslRuntime; use crate::error::AppError; use crate::storage::atomic_document::AtomicDocumentIo; @@ -142,6 +145,19 @@ impl RuntimeSkillLibraryRepository { ); Ok(candidates) } + + async fn usage_metadata( + &self, + environment: &EnvironmentRef, + ) -> HashMap> { + match self.usage_candidates(environment).await { + Ok(candidates) => candidates.into_iter().collect(), + Err(error) => { + log::warn!("Skill Library project metadata is unavailable: {error}"); + HashMap::new() + } + } + } } impl SkillLibraryRepository for RuntimeSkillLibraryRepository { @@ -212,11 +228,11 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { .await? } }; - let catalog = bytes - .map(|bytes| serde_json::from_slice(&bytes).map_err(AppError::from)) - .unwrap_or_else(|| Ok(LibraryCatalog::default()))?; - validate_catalog(&catalog)?; - Ok(catalog) + bytes + .as_deref() + .map(parse_library_catalog) + .transpose() + .map(|catalog| catalog.unwrap_or_default()) }) } @@ -397,12 +413,16 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { ) -> LibraryFuture<'a, Result, AppError>> { Box::pin(async move { let mut usages = Vec::new(); - for (context, project) in self.usage_candidates(environment).await? { - let record = self.load_application(&context).await?; - if let Some(state) = library_usage_state(&record, library_id) { + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + require_complete_inventory(&inventory)?; + let projects = self.usage_metadata(environment).await; + for application in inventory.records { + if let Some(state) = library_usage_state(&application.record, library_id) { + let context = application.context; usages.push(LibraryUsage { + project: projects.get(&context).cloned().flatten(), context, - project, state, }); } @@ -414,13 +434,21 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { fn usage_projection<'a>( &'a self, environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { + ) -> LibraryFuture<'a, Result> { Box::pin(async move { let mut accumulator = LibraryUsageAccumulator::default(); - for (context, _) in self.usage_candidates(environment).await? { - accumulator.observe(&self.load_application(&context).await?); + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + let inventory_complete = inventory.complete; + let problem_count = u32::try_from(inventory.problems.len()).unwrap_or(u32::MAX); + for application in inventory.records { + accumulator.observe(&application.record); } - Ok(accumulator.finish()) + Ok(LibraryUsageSnapshot { + projections: accumulator.finish(), + inventory_complete, + problem_count, + }) }) } @@ -431,11 +459,19 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { ) -> LibraryFuture<'a, Result, AppError>> { Box::pin(async move { let mut usages = Vec::new(); - for (context, project) in self.usage_candidates(environment).await? { - let record = self.load_application(&context).await?; - let state = if record.current.selected_agent_ids.contains(agent_id) { + let inventory = self.enumerate(environment).await?; + report_inventory_problems(&inventory); + require_complete_inventory(&inventory)?; + let projects = self.usage_metadata(environment).await; + for application in inventory.records { + let state = if application + .record + .current + .selected_agent_ids + .contains(agent_id) + { Some(LibraryUsageState::Confirmed) - } else if record.pending_operation.as_ref().is_some_and(|pending| { + } else if application.record.pending.as_ref().is_some_and(|pending| { pending .before_application .selected_agent_ids @@ -450,9 +486,10 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { None }; if let Some(state) = state { + let context = application.context; usages.push(LibraryUsage { + project: projects.get(&context).cloned().flatten(), context, - project, state, }); } @@ -462,17 +499,42 @@ impl LibraryUsageProvider for RuntimeSkillLibraryRepository { } } -impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { +fn report_inventory_problems(inventory: &ApplicationInventory) { + if inventory.complete { + return; + } + for problem in &inventory.problems { + log::warn!( + "Skill Library application inventory skipped {}: {}", + problem.storage_key, + problem.error + ); + } +} + +fn require_complete_inventory(inventory: &ApplicationInventory) -> Result<(), AppError> { + if inventory.complete { + Ok(()) + } else { + Err(AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }) + } +} + +impl ApplicationRegistry for RuntimeSkillLibraryRepository { fn load_application<'a>( &'a self, context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { - let bytes = match &context.environment { + let (target, snapshot) = match &context.environment { EnvironmentRef::Native => { - NativeAtomicDocumentIo - .read_optional(&self.native_application(context)?) - .await? + let target = self.native_application(context)?; + let snapshot = NativeAtomicDocumentIo + .observe(&target, u64::from(environment_protocol::MAX_DOCUMENT_BYTES)) + .await?; + (target, snapshot) } EnvironmentRef::Wsl { distro_name } => { let distro_name = distro_name.clone(); @@ -484,76 +546,147 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { let workspace = workspace.clone(); async move { let target = wsl_application_locator(&session, &context.scope)?; - workspace - .read_optional_document( - target.native_path, - environment_protocol::MAX_DOCUMENT_BYTES, - ) - .await + let snapshot = + WslAtomicDocumentIo::from_active_session(session, workspace) + .observe( + &target, + u64::from(environment_protocol::MAX_DOCUMENT_BYTES), + ) + .await?; + Ok((target, snapshot)) } }) .await? } }; - let record = bytes - .map(|bytes| serde_json::from_slice(&bytes).map_err(AppError::from)) + let record = snapshot + .bytes + .as_deref() + .map(parse_library_application_record) .unwrap_or_else(|| Ok(LibraryApplicationRecord::empty()))?; - if record.schema_version != LIBRARY_APPLICATION_SCHEMA_VERSION { - return Err(AppError::ConfigurationCorrupted { - message: "invalid Skill Library application record".to_string(), - }); - } - Ok(record) + Ok(VersionedApplicationRecord { + context: context.clone(), + record, + target, + snapshot, + }) }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { + validate_application_record(record)?; let bytes = serde_json::to_vec_pretty(record)?; - match &context.environment { + let snapshot = match &observed.context.environment { + EnvironmentRef::Native => NativeAtomicDocumentIo + .replace(&observed.target, observed.snapshot.clone(), bytes) + .await + .map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error)?, + EnvironmentRef::Wsl { distro_name } => { + let workspace = self.wsl.workspace(distro_name)?; + WslAtomicDocumentIo::new(workspace) + .replace(&observed.target, observed.snapshot.clone(), bytes) + .await + .map_err( + crate::storage::atomic_document::DocumentWriteFailure::into_error, + )? + } + }; + Ok(VersionedApplicationRecord { + context: observed.context.clone(), + record: record.clone(), + target: observed.target.clone(), + snapshot, + }) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + let contexts = match environment { EnvironmentRef::Native => { - NativeAtomicDocumentIo - .write_atomic(&self.native_application(context)?, bytes) + let root = self.native_root.join("applications"); + tokio::task::spawn_blocking(move || native_application_contexts(&root)) .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("application inventory task failed: {error}"), + })?? } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - let scope = context.scope.clone(); - let workspace = self.wsl.workspace(&distro_name)?; - self.wsl - .with_session(&distro_name, move |session| { - let bytes = bytes.clone(); - let scope = scope.clone(); - let workspace = workspace.clone(); - async move { - let target = wsl_application_locator(&session, &scope)?; - let snapshot = workspace - .read_optional_document_snapshot_once( - target.native_path.clone(), - environment_protocol::MAX_DOCUMENT_BYTES, - ) - .await?; - workspace - .write_document_atomic( - snapshot.generation, - target.native_path, - snapshot.revision, - bytes, - ) - .await - .map(|_| ()) - } + let index = self + .wsl + .workspace(distro_name)? + .list_library_applications() + .await?; + let mut scopes = vec![("global".to_string(), SkillLocation::Global)]; + let mut problems = index + .problem_keys + .into_iter() + .map(|storage_key| ApplicationInventoryProblem { + storage_key, + error: AppError::ConfigurationCorrupted { + message: "invalid Skill Library application Scope key".to_string(), + }, }) - .await + .collect::>(); + for project_id in index.project_ids { + if let Err(error) = validate_storage_component(&project_id) { + problems.push(ApplicationInventoryProblem { + storage_key: format!("projects/{project_id}.json"), + error, + }); + continue; + } + scopes.push(( + format!("projects/{project_id}.json"), + SkillLocation::Project { project_id }, + )); + } + if !index.complete && problems.is_empty() { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: AppError::ConfigurationCorrupted { + message: "incomplete Skill Library application inventory" + .to_string(), + }, + }); + } + ApplicationContexts { scopes, problems } + } + }; + let mut records = Vec::new(); + let mut problems = contexts.problems; + for (storage_key, scope) in contexts.scopes { + let context = SkillLocationRef { + environment: environment.clone(), + scope, + }; + match self.load_application(&context).await { + Ok(record) => records.push(record), + Err(error) => problems.push(ApplicationInventoryProblem { storage_key, error }), } } + records.sort_by(|left, right| { + application_scope_key(&left.context.scope) + .cmp(&application_scope_key(&right.context.scope)) + }); + Ok(ApplicationInventory { + complete: problems.is_empty(), + records, + problems, + }) }) } +} +impl LibraryApplicationResources for RuntimeSkillLibraryRepository { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -606,59 +739,31 @@ impl LibraryApplicationRepository for RuntimeSkillLibraryRepository { Box::pin(async move { SkillLibraryRepository::load(self, &context.environment).await }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { - let SkillLocation::Project { project_id } = &context.scope else { + let SkillLocation::Project { project_id } = &observed.context.scope else { return Err(AppError::Validation { field: Some("context".to_string()), message: "only Project Skill Library applications can be removed".to_string(), }); }; validate_storage_component(project_id)?; - match &context.environment { + let result = match &observed.context.environment { EnvironmentRef::Native => { - let path = PathBuf::from(self.native_application(context)?.native_path); - tokio::task::spawn_blocking(move || match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(AppError::from(error)), - }) - .await - .map_err(|error| AppError::ExecutionFailed { - message: format!("Skill Library application cleanup task failed: {error}"), - })? + NativeAtomicDocumentIo + .remove(&observed.target, observed.snapshot.clone()) + .await } EnvironmentRef::Wsl { distro_name } => { - let distro_name = distro_name.clone(); - let scope = context.scope.clone(); - let workspace = self.wsl.workspace(&distro_name)?; - self.wsl - .with_session(&distro_name, move |session| { - let scope = scope.clone(); - let workspace = workspace.clone(); - async move { - let target = wsl_application_locator(&session, &scope)?; - let snapshot = workspace - .read_optional_document_snapshot_once( - target.native_path.clone(), - environment_protocol::MAX_DOCUMENT_BYTES, - ) - .await?; - workspace - .remove_document_if_revision( - snapshot.generation, - target.native_path, - snapshot.revision, - ) - .await - } - }) + WslAtomicDocumentIo::new(self.wsl.workspace(distro_name)?) + .remove(&observed.target, observed.snapshot.clone()) .await } - } + }; + result.map_err(crate::storage::atomic_document::DocumentWriteFailure::into_error) }) } } @@ -700,6 +805,26 @@ fn load_native_catalog_bytes(root: &Path) -> Result>, AppError> { Ok(bytes) } +fn parse_library_catalog(bytes: &[u8]) -> Result { + let catalog = serde_json::from_slice(bytes) + .map_err(|error| invalid_library_document("catalog", error))?; + validate_catalog(&catalog)?; + Ok(catalog) +} + +fn parse_library_application_record(bytes: &[u8]) -> Result { + let record = serde_json::from_slice(bytes) + .map_err(|error| invalid_library_document("application record", error))?; + validate_application_record(&record)?; + Ok(record) +} + +fn invalid_library_document(kind: &str, error: serde_json::Error) -> AppError { + AppError::ConfigurationCorrupted { + message: format!("Skill Library {kind} does not match the current data format: {error}"), + } +} + fn save_native_catalog( root: &Path, library_ids: &[LibraryId], @@ -745,8 +870,7 @@ fn delete_native_library(root: &Path, library_id: &LibraryId) -> Result Res let mut catalog = current_catalog_bytes .as_deref() - .map(serde_json::from_slice) + .map(parse_library_catalog) .transpose()? .unwrap_or_default(); - validate_catalog(&catalog)?; let snapshot = crate::application::collection_records::LibraryCatalogRecordReader::new( &catalog, &request.library_id, @@ -1505,6 +1627,113 @@ fn application_relative_path(scope: &SkillLocation) -> Result } } +struct ApplicationContexts { + scopes: Vec<(String, SkillLocation)>, + problems: Vec, +} + +fn native_application_contexts(root: &Path) -> Result { + let mut scopes = vec![("global".to_string(), SkillLocation::Global)]; + let mut problems = Vec::new(); + match fs::read_dir(root) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: error.into(), + }); + continue; + } + }; + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "global.json" || name == "projects" { + continue; + } + problems.push(ApplicationInventoryProblem { + storage_key: name, + error: AppError::ConfigurationCorrupted { + message: "unknown Skill Library application entry".to_string(), + }, + }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ApplicationContexts { scopes, problems }); + } + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "applications".to_string(), + error: error.into(), + }); + return Ok(ApplicationContexts { scopes, problems }); + } + } + + let projects = root.join("projects"); + match fs::read_dir(&projects) { + Ok(entries) => { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + problems.push(ApplicationInventoryProblem { + storage_key: "projects".to_string(), + error: error.into(), + }); + continue; + } + }; + let name = entry.file_name(); + let display = name.to_string_lossy().into_owned(); + let project_id = Path::new(&name) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|_| { + Path::new(&name) + .extension() + .is_some_and(|value| value == "json") + }); + let valid = entry.file_type().is_ok_and(|kind| kind.is_file()) + && project_id + .is_some_and(|project_id| validate_storage_component(project_id).is_ok()); + if let Some(project_id) = project_id.filter(|_| valid) { + scopes.push(( + format!("projects/{display}"), + SkillLocation::Project { + project_id: project_id.to_string(), + }, + )); + } else { + problems.push(ApplicationInventoryProblem { + storage_key: format!("projects/{display}"), + error: AppError::ConfigurationCorrupted { + message: "invalid Skill Library application Scope key".to_string(), + }, + }); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => problems.push(ApplicationInventoryProblem { + storage_key: "projects".to_string(), + error: error.into(), + }), + } + scopes.sort_by(|left, right| left.0.cmp(&right.0)); + problems.sort_by(|left, right| left.storage_key.cmp(&right.storage_key)); + Ok(ApplicationContexts { scopes, problems }) +} + +fn application_scope_key(scope: &SkillLocation) -> String { + match scope { + SkillLocation::Global => "global".to_string(), + SkillLocation::Project { project_id } => format!("project:{project_id}"), + } +} + fn wsl_application_locator( session: &crate::environment::wsl::WslSession, scope: &SkillLocation, @@ -1528,7 +1757,7 @@ mod tests { use super::*; use crate::application::library_application::{ - LibraryApplicationRecord, LibraryApplicationRepository, LibraryApplicationState, + ApplicationRegistry, LibraryApplicationRecord, LibraryApplicationState, }; use crate::application::skill_libraries::SkillLibraryModule; use crate::application::skill_libraries::LIBRARY_SCHEMA_VERSION; @@ -2202,27 +2431,74 @@ mod tests { selected_agent_ids: Vec::new(), }; + let global_snapshot = repository.load_application(&global).await.unwrap(); repository - .save_application(&global, &global_record) + .save_application_if(&global_snapshot, &global_record) .await .unwrap(); + let project_snapshot = repository.load_application(&project).await.unwrap(); repository - .save_application(&project, &project_record) + .save_application_if(&project_snapshot, &project_record) .await .unwrap(); assert_eq!( - repository.load_application(&global).await.unwrap(), + repository.load_application(&global).await.unwrap().record, global_record ); assert_eq!( - repository.load_application(&project).await.unwrap(), + repository.load_application(&project).await.unwrap().record, project_record ); } #[tokio::test] - async fn application_record_storage_uses_its_repository_context() { + async fn project_application_removal_is_bound_to_the_loaded_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + temp.path().join("libraries"), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let project = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Project { + project_id: "project-1".to_string(), + }, + }; + let missing = repository.load_application(&project).await.unwrap(); + let saved = repository + .save_application_if(&missing, &LibraryApplicationRecord::empty()) + .await + .unwrap(); + fs::write(&saved.target.native_path, b"external change").unwrap(); + + let error = repository.remove_application_if(&saved).await.unwrap_err(); + + assert!(matches!(error, AppError::StaleTarget)); + assert_eq!( + fs::read(&saved.target.native_path).unwrap(), + b"external change" + ); + } + + #[test] + fn existing_schema_one_application_record_is_accepted() { + let record = parse_library_application_record( + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "checkpoint": { "members": [] }, + "pending": null + }"#, + ) + .expect("schema 1 is the current application record format"); + + assert_eq!(record.schema_version, 1); + } + + #[tokio::test] + async fn application_record_accepts_unknown_route_fields_and_saves_canonical_schema() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("libraries"); let applications = root.join("applications"); @@ -2236,7 +2512,8 @@ mod tests { "scope": { "scope": "global" } }, "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, - "pendingOperation": null + "checkpoint": { "members": [] }, + "pending": null }"#, ) .unwrap(); @@ -2253,7 +2530,7 @@ mod tests { let record = repository.load_application(&context).await.unwrap(); assert_eq!(record.current, LibraryApplicationState::default()); repository - .save_application(&context, &record) + .save_application_if(&record, &record.record) .await .unwrap(); @@ -2262,6 +2539,172 @@ mod tests { assert!(stored.get("target").is_none()); } + #[tokio::test] + async fn application_record_without_checkpoint_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "pendingOperation": null + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + let error = repository.load_application(&context).await.unwrap_err(); + assert!(matches!( + error, + AppError::ConfigurationCorrupted { message } + if message.contains("Skill Library application record") + && message.contains("checkpoint") + )); + } + + #[tokio::test] + async fn application_record_with_incomplete_pending_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + fs::create_dir_all(&applications).unwrap(); + fs::write( + applications.join("global.json"), + br#"{ + "schemaVersion": 1, + "current": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "checkpoint": { "members": [] }, + "pending": { + "reconciliationId": "", + "attention": "pending", + "reasons": [], + "beforeApplication": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "targetApplication": { "orderedLibraryIds": [], "selectedAgentIds": [] }, + "recognizedMembers": [], + "targetMembers": [{ "libraryId": "library-a", "memberName": "demo" }] + } + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + + assert!(matches!( + repository.load_application(&context).await, + Err(AppError::ConfigurationCorrupted { .. }) + )); + } + + #[tokio::test] + async fn application_commit_rejects_a_changed_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let repository = RuntimeSkillLibraryRepository::new( + root.clone(), + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + let context = SkillLocationRef { + environment: EnvironmentRef::Native, + scope: SkillLocation::Global, + }; + let observed = repository.load_application(&context).await.unwrap(); + let external = LibraryApplicationRecord::empty(); + let path = root.join("applications/global.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, serde_json::to_vec_pretty(&external).unwrap()).unwrap(); + let mut replacement = LibraryApplicationRecord::empty(); + replacement.current.ordered_library_ids = vec![LibraryId::parse("library-a")]; + + assert!(matches!( + repository + .save_application_if(&observed, &replacement) + .await, + Err(AppError::StaleTarget) + )); + assert_eq!( + serde_json::from_slice::(&fs::read(path).unwrap()).unwrap(), + external + ); + } + + #[tokio::test] + async fn native_application_inventory_includes_orphans_and_reports_problems() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + let applications = root.join("applications"); + let project_records = applications.join("projects"); + fs::create_dir_all(&project_records).unwrap(); + let bytes = serde_json::to_vec_pretty(&LibraryApplicationRecord::empty()).unwrap(); + fs::write(applications.join("global.json"), &bytes).unwrap(); + fs::write(project_records.join("registered.json"), &bytes).unwrap(); + fs::write(project_records.join("orphan.json"), &bytes).unwrap(); + fs::write(project_records.join("broken.json"), b"{").unwrap(); + let mut future: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + future["schemaVersion"] = serde_json::json!( + crate::application::library_application::LIBRARY_APPLICATION_SCHEMA_VERSION + 1 + ); + fs::write( + project_records.join("future.json"), + serde_json::to_vec_pretty(&future).unwrap(), + ) + .unwrap(); + fs::create_dir(project_records.join("not-a-record.json")).unwrap(); + fs::write(applications.join("unexpected.json"), &bytes).unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let inventory = repository.enumerate(&EnvironmentRef::Native).await.unwrap(); + + assert!(!inventory.complete); + assert_eq!(inventory.records.len(), 3); + assert!(inventory + .records + .iter() + .any(|entry| { matches!(entry.context.scope, SkillLocation::Global) })); + assert!(inventory.records.iter().any(|entry| { + matches!( + &entry.context.scope, + SkillLocation::Project { project_id } if project_id == "registered" + ) + })); + assert!(inventory.records.iter().any(|entry| { + matches!( + &entry.context.scope, + SkillLocation::Project { project_id } if project_id == "orphan" + ) + })); + assert_eq!(inventory.problems.len(), 4); + + let usage = repository + .usage_projection(&EnvironmentRef::Native) + .await + .unwrap(); + assert!(!usage.inventory_complete); + assert_eq!(usage.problem_count, 4); + } + #[tokio::test] async fn native_repository_restores_a_retired_member_if_delete_crashes_after_rename() { let temp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/test_support/native_workflow.rs b/src-tauri/src/test_support/native_workflow.rs index 2c2be4c0..9d71223d 100644 --- a/src-tauri/src/test_support/native_workflow.rs +++ b/src-tauri/src/test_support/native_workflow.rs @@ -19,8 +19,10 @@ use crate::application::install_planner::ConcreteInstallPlanner; use crate::application::installed_skill_payload::InstalledSkillPayloadAcquirer; use crate::application::installed_skill_resolver::SkillDirectoryName; use crate::application::library_application::{ - ApplyLibraryApplicationRequest, LibraryApplicationDraft, LibraryApplicationFuture, - LibraryApplicationModule, LibraryApplicationRecord, LibraryApplicationRepository, + ApplicationInventory, ApplicationRegistry, ApplyLibraryApplicationRequest, + LibraryApplicationBackend, LibraryApplicationDraft, LibraryApplicationFuture, + LibraryApplicationModule, LibraryApplicationRecord, LibraryApplicationResources, + VersionedApplicationRecord, }; use crate::application::library_candidates::LibraryCandidateSet; use crate::application::library_candidates::{ @@ -131,31 +133,60 @@ async fn observe_skill( .map_err(|error| error.into_app_error()) } -struct MemoryLibraryApplicationRepository { +struct MemoryApplicationRegistry { record: Mutex, catalog: LibraryCatalog, members_root: PathBuf, } -impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { +impl ApplicationRegistry for MemoryApplicationRegistry { fn load_application<'a>( &'a self, - _context: &'a SkillLocationRef, - ) -> LibraryApplicationFuture<'a, Result> { - Box::pin(async move { Ok(self.record.lock().expect("library record lock").clone()) }) + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(VersionedApplicationRecord::in_memory( + context.clone(), + self.record.lock().expect("library record lock").clone(), + )) + }) } - fn save_application<'a>( + fn save_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + observed: &'a VersionedApplicationRecord, record: &'a LibraryApplicationRecord, - ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { + ) -> LibraryApplicationFuture<'a, Result> { Box::pin(async move { *self.record.lock().expect("library record lock") = record.clone(); - Ok(()) + Ok(VersionedApplicationRecord::in_memory( + observed.context.clone(), + record.clone(), + )) + }) + } + + fn enumerate<'a>( + &'a self, + environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: vec![VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: environment.clone(), + scope: SkillLocation::Global, + }, + self.record.lock().expect("library record lock").clone(), + )], + problems: Vec::new(), + complete: true, + }) }) } +} +impl LibraryApplicationResources for MemoryApplicationRegistry { fn library_skill_locator<'a>( &'a self, context: &'a SkillLocationRef, @@ -188,9 +219,9 @@ impl LibraryApplicationRepository for MemoryLibraryApplicationRepository { Box::pin(async move { Ok(self.catalog.clone()) }) } - fn remove_application<'a>( + fn remove_application_if<'a>( &'a self, - _context: &'a SkillLocationRef, + _observed: &'a VersionedApplicationRecord, ) -> LibraryApplicationFuture<'a, Result<(), AppError>> { Box::pin(async move { *self.record.lock().expect("library record lock") = LibraryApplicationRecord::empty(); @@ -1427,19 +1458,18 @@ async fn run_native_scope_version_election_workflow_at(root: &Path) -> Result<() let context = project_context("version-election"); let first_id = LibraryId::parse("library-one"); let second_id = LibraryId::parse("library-two"); - let repository: Arc = - Arc::new(MemoryLibraryApplicationRepository { - record: Mutex::new(LibraryApplicationRecord::empty()), - catalog: LibraryCatalog { - schema_version: LIBRARY_SCHEMA_VERSION, - libraries: vec![ - test_library_record(first_id.clone(), "Library One", "library-one"), - test_library_record(second_id.clone(), "Library Two", "library-two"), - ], - extra: serde_json::Map::new(), - }, - members_root, - }); + let repository: Arc = Arc::new(MemoryApplicationRegistry { + record: Mutex::new(LibraryApplicationRecord::empty()), + catalog: LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![ + test_library_record(first_id.clone(), "Library One", "library-one"), + test_library_record(second_id.clone(), "Library Two", "library-two"), + ], + extra: serde_json::Map::new(), + }, + members_root, + }); let registry = Arc::new(StaticRegistry(Arc::new(test_registry()))); let environments = Arc::new(WslRuntime::default()); let facts = RuntimePlanningFactSource::with_native_snapshot( diff --git a/src/bindings.ts b/src/bindings.ts index 71a805b1..2646781b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -929,7 +929,8 @@ export type LibraryApplicationDraft = { context: SkillLocationRef; orderedLibrar export type LibraryApplicationPreview = { token: PreviewToken; current: LibraryApplicationState; target: LibraryApplicationState; addedSkillNames: string[]; removedSkillNames: string[]; switchedSkillNames: string[]; changedDirectorySkillNames: string[]; overriddenByDirectSkillNames: string[] } export type LibraryApplicationResponse = { application: LibraryApplicationSummary; units: MutationUnitResult[] } export type LibraryApplicationState = { orderedLibraryIds: LibraryId[]; selectedAgentIds: AgentId[] } -export type LibraryApplicationSummary = { orderedLibraries: SkillLibrarySummary[]; selectedAgentIds: AgentId[]; pending: boolean } +export type LibraryApplicationSummary = { orderedLibraries: SkillLibrarySummary[]; selectedAgentIds: AgentId[]; pending: boolean; syncState: LibraryApplicationSyncState } +export type LibraryApplicationSyncState = "synced" | "pending" | "unverified" | "recoveryRequired" export type LibraryCommitStatus = "succeeded" | "failed" | "notRun" export type LibraryId = string export type LibrarySkillSummary = { name: string; description: string; source: string; sourceType: string; sourceUrl: string | null; skillPath: string; contentHash: string; @@ -1093,7 +1094,7 @@ export type SourceUpdateCheckInfo = { source: string; requestedRef: string | nul export type StorageAccess = "native" | "crossStorage" | "unsupported" | "unknown" export type SuggestedActionCode = "reviewChanges" | "refresh" | "openRecoveryResource" | "saveDefaultsLater" export type UnavailableAgentSelection = { agentId: string; reason: UnavailableAgentSelectionReason } -export type UnavailableAgentSelectionReason = "definitionMissing" +export type UnavailableAgentSelectionReason = "definitionMissing" | "optionUnavailable" export type UpdateCapabilityReasonCode = "missingRemoteHash" | "missingSource" | "unsupportedSource" export type UpdateCheckMode = "automatic" | "force" export type UpdateCheckOutcome = "completed" | "partial" | "notCompleted" From 8962e97c15e0048d342002b0e5dac89eaad7688f Mon Sep 17 00:00:00 2001 From: Hccake Date: Tue, 8 Sep 2026 23:22:51 +0800 Subject: [PATCH 10/27] feat(libraries): support membership changes in applied libraries Allow applied libraries to add, update, retire and reactivate members. Centralize impact previews, admission, member commits, Scope reconciliation and conditional retired-content cleanup. Wire retirement previews and membership resume commands into runtime maintenance, and preserve committed member results when snapshot loading fails. --- src-tauri/app_commands.rs | 2 + .../permissions/window-command-sets.toml | 2 + .../src/application/collection_records.rs | 4 +- .../src/application/library_membership.rs | 1625 +++++++++++++++++ .../application/library_membership/state.rs | 146 ++ src-tauri/src/application/library_update.rs | 141 +- src-tauri/src/application/mod.rs | 1 + src-tauri/src/application/skill_libraries.rs | 770 ++++++-- src-tauri/src/application/skill_paths.rs | 4 +- src-tauri/src/application/update_subjects.rs | 1 + src-tauri/src/commands/skill_libraries.rs | 58 +- src-tauri/src/runtime/maintenance.rs | 60 + src-tauri/src/runtime/mod.rs | 25 +- src-tauri/src/runtime/skill_libraries.rs | 451 ++++- src-tauri/src/test_support/native_workflow.rs | 1 + src/bindings.ts | 44 +- src/hooks/useTauriApi.ts | 23 +- 17 files changed, 3080 insertions(+), 278 deletions(-) create mode 100644 src-tauri/src/application/library_membership.rs create mode 100644 src-tauri/src/application/library_membership/state.rs diff --git a/src-tauri/app_commands.rs b/src-tauri/app_commands.rs index 7217c417..f55976e7 100644 --- a/src-tauri/app_commands.rs +++ b/src-tauri/app_commands.rs @@ -16,10 +16,12 @@ macro_rules! with_app_commands { list_skill_libraries => [commands::skill_libraries::list_skill_libraries], preview_library_application => [commands::skill_libraries::preview_library_application], preview_add_library_skills => [commands::skill_libraries::preview_add_library_skills], + preview_remove_library_skill => [commands::skill_libraries::preview_remove_library_skill], preview_library_skill_updates => [commands::skill_libraries::preview_library_skill_updates], read_library_skill_content => [commands::skill_libraries::read_library_skill_content], rename_skill_library => [commands::skill_libraries::rename_skill_library], remove_library_skill => [commands::skill_libraries::remove_library_skill], + resume_library_membership => [commands::skill_libraries::resume_library_membership], retry_library_application => [commands::skill_libraries::retry_library_application], update_library_skills => [commands::skill_libraries::update_library_skills], list_agents => [commands::agents::list_agents], diff --git a/src-tauri/permissions/window-command-sets.toml b/src-tauri/permissions/window-command-sets.toml index bd462ab5..3dc568e9 100644 --- a/src-tauri/permissions/window-command-sets.toml +++ b/src-tauri/permissions/window-command-sets.toml @@ -62,6 +62,7 @@ permissions = [ "allow-open-recovery-resource", "allow-open-skill-resource", "allow-preview-add-library-skills", + "allow-preview-remove-library-skill", "allow-preview-copy-skill-to-projects", "allow-preview-custom-agent-delete", "allow-preview-library-application", @@ -73,6 +74,7 @@ permissions = [ "allow-read-skill-content", "allow-remove-environment-project", "allow-remove-library-skill", + "allow-resume-library-membership", "allow-remove-skill", "allow-rename-skill-library", "allow-retry-library-application", diff --git a/src-tauri/src/application/collection_records.rs b/src-tauri/src/application/collection_records.rs index 911558fe..7f63b0d8 100644 --- a/src-tauri/src/application/collection_records.rs +++ b/src-tauri/src/application/collection_records.rs @@ -621,7 +621,8 @@ mod tests { "description": "Broken", "sourceRecord": { "sourceType": 42 }, "contentManifestHash": "manifest-broken" - }] + }], + "retiredSkills": [] }] })) .expect("catalog envelope remains readable"); @@ -650,6 +651,7 @@ mod tests { id: library_id.clone(), name: "Library".to_string(), skills, + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/application/library_membership.rs b/src-tauri/src/application/library_membership.rs new file mode 100644 index 00000000..cea4dedb --- /dev/null +++ b/src-tauri/src/application/library_membership.rs @@ -0,0 +1,1625 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +mod state; + +use crate::application::library_application::{ + ApplicationInventory, ApplicationRegistry, LibraryApplicationModule, + LibraryApplicationResponse, LibraryApplicationScopePlan, LibraryApplicationSyncState, + ReconciliationAttention, +}; +use crate::application::mutation::executor::MutationPlanExecutor; +use crate::application::payload_session::PayloadSessionManager; +use crate::application::planning_facts::ScopePlanningSnapshotSource; +use crate::application::runtime_admission::RuntimeAdmissionCoordinator; +use crate::application::scope_skill_planning::ScopeSkillPlanner; +use crate::application::skill_libraries::{ + ExecuteAddLibrarySkillsRequest, ExecuteRetireLibrarySkillRequest, LibraryAddPreview, + LibraryAddResponse, LibraryCatalog, LibraryId, LibraryRetirePreview, LibraryRetireResponse, + PreviewAddLibrarySkillsRequest, PurgeRetiredLibraryMemberRequest, RemoveLibrarySkillRequest, + SkillLibraryModule, SkillLibraryRepository, +}; +use crate::core::mutation::CancellationSignal; +use crate::core::mutation::MutationKind; +use crate::environment::content_manifest::ContentManifestReader; +use crate::environment::planning::TargetFactResolver; +use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::error::AppError; +#[cfg(test)] +use state::MembershipChangeKind; +pub(crate) use state::{apply_membership_change, MembershipChange}; + +pub(crate) trait LibraryTargetFacts: TargetFactResolver + ContentManifestReader {} + +impl LibraryTargetFacts for T where T: TargetFactResolver + ContentManifestReader {} + +pub(crate) type MembershipFuture<'a, T> = Pin + Send + 'a>>; + +pub(crate) trait LibraryApplicationMembership: Send + Sync { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result>; + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result>; + + fn record_attention<'a>( + &'a self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>>; + + fn resume<'a>( + &'a self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result>; +} + +impl LibraryApplicationMembership for LibraryApplicationModule +where + F: ScopePlanningSnapshotSource + Send + Sync, + T: TargetFactResolver + Send + Sync, + E: MutationPlanExecutor + Send + Sync, +{ + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { LibraryApplicationModule::plan_resume(self, context).await }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + LibraryApplicationModule::plan_resume_with_catalog(self, context, catalog).await + }) + } + + fn record_attention<'a>( + &'a self, + context: SkillLocationRef, + attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async move { + LibraryApplicationModule::record_reconciliation_attention(self, context, attention) + .await + }) + } + + fn resume<'a>( + &'a self, + context: SkillLocationRef, + cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { LibraryApplicationModule::resume(self, context, cancellation).await }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryMembershipPreview { + pub environment: EnvironmentRef, + pub library_id: LibraryId, + pub scopes: Vec, + pub impacts: Vec, + pub inventory_complete: bool, + pub inventory_token: String, + pub token: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum MembershipScopeImpactKind { + Added, + Switched, + Fallback, + Removed, + Unchanged, + Unverified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipSkillImpact { + pub skill_name: String, + pub kind: MembershipScopeImpactKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipScopeImpact { + pub context: SkillLocationRef, + pub skills: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum MembershipScopeState { + Synced, + Pending, + Unverified, + RecoveryRequired, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct MembershipScopeResult { + pub context: SkillLocationRef, + pub state: MembershipScopeState, + pub error: Option, +} + +#[derive(Debug)] +pub(crate) struct LibraryMembershipExecution { + pub library: Option, + pub scopes: Vec, + pub cleanup: Vec, + pub snapshot_error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub enum RetiredCleanupState { + Purged, + Retained, + Failed, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct RetiredCleanupResult { + pub library_id: LibraryId, + pub member_name: String, + pub retirement_id: String, + pub state: RetiredCleanupState, + pub error: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryMembershipOutcome { + pub scopes: Vec, + pub cleanup: Vec, + pub snapshot_error: Option, +} + +impl LibraryMembershipExecution { + pub(crate) fn into_parts(self) -> (Option, LibraryMembershipOutcome) { + ( + self.library, + LibraryMembershipOutcome { + scopes: self.scopes, + cleanup: self.cleanup, + snapshot_error: self.snapshot_error, + }, + ) + } +} + +struct RetiredMemberCollector { + applications: Arc, + libraries: Arc, +} + +struct MembershipReconciliationModule { + applications: Arc, + reconciler: Arc, + collector: RetiredMemberCollector, +} + +impl MembershipReconciliationModule { + fn new( + applications: Arc, + reconciler: Arc, + libraries: Arc, + ) -> Self { + Self { + collector: RetiredMemberCollector { + applications: applications.clone(), + libraries, + }, + applications, + reconciler, + } + } + + pub(crate) async fn preview( + &self, + environment: EnvironmentRef, + library_id: LibraryId, + ) -> Result { + let inventory = self.applications.enumerate(&environment).await?; + membership_preview(environment, library_id, &inventory) + } + + pub(crate) async fn execute( + &self, + preview: LibraryMembershipPreview, + commit: F, + cancellation: CancellationSignal, + ) -> Result, AppError> + where + F: Future>, + { + let current = self + .preview(preview.environment.clone(), preview.library_id.clone()) + .await?; + if current.inventory_token != preview.inventory_token { + return Err(AppError::StaleContext); + } + let library = commit.await?; + let mut result = self + .reconcile_scopes(current.scopes, current.inventory_complete, cancellation) + .await; + match self + .collect_retired(&preview.environment, Some(&preview.library_id)) + .await + { + Ok(cleanup) => result.cleanup = cleanup, + Err(error) if result.snapshot_error.is_none() => result.snapshot_error = Some(error), + Err(_) => {} + } + result.library = Some(library); + Ok(result) + } + + pub(crate) async fn resume( + &self, + environment: EnvironmentRef, + library_id: Option, + cancellation: CancellationSignal, + ) -> Result, AppError> { + let inventory = self.applications.enumerate(&environment).await?; + let mut scopes = inventory + .records + .iter() + .filter(|application| { + library_id + .as_ref() + .is_none_or(|library_id| references_library(&application.record, library_id)) + }) + .map(|application| application.context.clone()) + .collect::>(); + sort_scopes(&mut scopes); + let mut result = self + .reconcile_scopes(scopes, inventory.complete, cancellation) + .await; + match self + .collect_retired(&environment, library_id.as_ref()) + .await + { + Ok(cleanup) => result.cleanup = cleanup, + Err(error) if result.snapshot_error.is_none() => result.snapshot_error = Some(error), + Err(_) => {} + } + Ok(result) + } + + async fn reconcile_scopes( + &self, + scopes: Vec, + inventory_complete: bool, + cancellation: CancellationSignal, + ) -> LibraryMembershipExecution { + let mut snapshot_error = None; + let mut plans = Vec::with_capacity(scopes.len()); + let mut planning_results = Vec::with_capacity(scopes.len()); + for context in &scopes { + if cancellation.is_cancelled() { + plans.push(None); + planning_results.push(Some(MembershipScopeResult { + context: context.clone(), + state: MembershipScopeState::Cancelled, + error: Some(AppError::MutationCancelled), + })); + continue; + } + match self.reconciler.plan(context.clone()).await { + Ok(plan) => { + plans.push(Some(plan)); + planning_results.push(None); + } + Err(error) => { + plans.push(None); + planning_results.push(Some(MembershipScopeResult { + context: context.clone(), + state: membership_error_state(&error), + error: Some(error), + })); + } + } + } + let conflicts = conflicting_scope_plans(plans.iter().flatten()); + let mut results = Vec::with_capacity(scopes.len()); + for (index, context) in scopes.into_iter().enumerate() { + if let Some(result) = planning_results[index].take() { + let attention = match result.state { + MembershipScopeState::Unverified => ReconciliationAttention::Unverified, + _ => ReconciliationAttention::Pending, + }; + if let Err(error) = self + .reconciler + .record_attention(context.clone(), attention) + .await + { + snapshot_error.get_or_insert(error); + } + results.push(result); + continue; + } + if conflicts.contains(&context) { + if let Err(error) = self + .reconciler + .record_attention(context.clone(), ReconciliationAttention::Pending) + .await + { + snapshot_error.get_or_insert(error); + } + results.push(MembershipScopeResult { + context, + state: MembershipScopeState::Pending, + error: Some(AppError::StaleTarget), + }); + continue; + } + match self + .reconciler + .resume(context.clone(), cancellation.clone()) + .await + { + Ok(application) => results.push(MembershipScopeResult { + state: membership_scope_state(application.application.sync_state), + context, + error: None, + }), + Err(error) => results.push(MembershipScopeResult { + state: membership_error_state(&error), + context, + error: Some(error), + }), + } + } + LibraryMembershipExecution { + library: None, + scopes: results, + cleanup: Vec::new(), + snapshot_error: snapshot_error.or_else(|| { + (!inventory_complete).then(|| AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }) + }), + } + } + + async fn collect_retired( + &self, + environment: &EnvironmentRef, + library_id: Option<&LibraryId>, + ) -> Result, AppError> { + self.collector.collect(environment, library_id).await + } +} + +pub(crate) struct LibraryMembershipModule { + reconciliation: MembershipReconciliationModule, + libraries: Arc, + payloads: Arc, + targets: Arc, + admission: Arc, +} + +impl LibraryMembershipModule { + pub(crate) fn new( + applications: Arc, + reconciler: Arc, + repository: Arc, + admission: Arc, + libraries: Arc, + payloads: Arc, + targets: Arc, + ) -> Self { + Self { + reconciliation: MembershipReconciliationModule::new( + applications, + reconciler, + repository, + ), + libraries, + payloads, + targets, + admission, + } + } + + pub(crate) async fn preview_add_skills( + &self, + request: PreviewAddLibrarySkillsRequest, + ) -> Result { + let preview_request = request.clone(); + let membership = self + .reconciliation + .preview(request.environment.clone(), request.library_id.clone()) + .await?; + let (mut preview, projected) = self + .libraries + .preview_add_skills_with_catalog( + self.payloads.as_ref(), + self.targets.as_ref(), + request, + membership, + ) + .await?; + let membership = self + .preview_impacts( + preview.membership.clone(), + projected, + preview + .skills + .iter() + .map(|skill| skill.skill_name.clone()) + .collect(), + false, + ) + .await?; + preview.bind_membership(&preview_request, membership)?; + Ok(preview) + } + + pub(crate) async fn execute_add_skills( + &self, + request: ExecuteAddLibrarySkillsRequest, + cancellation: CancellationSignal, + ) -> Result { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + request.request.environment.clone(), + request.request.library_id.as_str().to_string(), + )?; + let current = self.preview_add_skills(request.request.clone()).await?; + if current.token != request.expected_token + || current.membership.token != request.membership.token + { + return Err(AppError::StaleContext); + } + let preview = current.membership.clone(); + let request = ExecuteAddLibrarySkillsRequest { + membership: current.membership, + ..request + }; + let commit = self.libraries.execute_add_skills( + self.payloads.as_ref(), + self.targets.as_ref(), + request, + ); + let execution = self + .reconciliation + .execute(preview, commit, cancellation) + .await?; + let (commit, mut membership) = execution.into_parts(); + let commit = commit.ok_or(AppError::StaleContext)?; + if membership.snapshot_error.is_none() { + membership.snapshot_error = commit.snapshot_error; + } + Ok(LibraryAddResponse { + results: commit.results, + library: commit.library, + membership, + }) + } + + pub(crate) async fn preview_retire_skill( + &self, + request: RemoveLibrarySkillRequest, + ) -> Result { + let membership = self + .reconciliation + .preview(request.environment.clone(), request.library_id.clone()) + .await?; + let (prepared, projected) = self + .libraries + .prepare_retire_skill_with_catalog(self.targets.as_ref(), request, membership.clone()) + .await?; + let skill_name = prepared.skill_name().to_string(); + let membership = self + .preview_impacts(membership, projected, vec![skill_name], true) + .await?; + prepared.bind(membership) + } + + pub(crate) async fn retire_skill( + &self, + request: ExecuteRetireLibrarySkillRequest, + cancellation: CancellationSignal, + ) -> Result { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + request.request.environment.clone(), + request.request.library_id.as_str().to_string(), + )?; + let current = self.preview_retire_skill(request.request.clone()).await?; + if current.token != request.expected_token + || current.membership.token != request.membership.token + { + return Err(AppError::StaleContext); + } + let preview = current.membership.clone(); + let request = ExecuteRetireLibrarySkillRequest { + membership: current.membership, + ..request + }; + let commit = self.libraries.retire_skill(self.targets.as_ref(), request); + let execution = self + .reconciliation + .execute(preview, commit, cancellation) + .await?; + let (commit, mut membership) = execution.into_parts(); + let commit = commit.ok_or(AppError::StaleContext)?; + if membership.snapshot_error.is_none() { + membership.snapshot_error = commit.snapshot_error; + } + Ok(LibraryRetireResponse { + library: commit.library, + membership, + }) + } + + pub(crate) async fn resume( + &self, + environment: EnvironmentRef, + library_id: Option, + cancellation: CancellationSignal, + ) -> Result, AppError> { + let _permit = self.admission.begin_library_mutation( + MutationKind::ManageLibraries, + environment.clone(), + library_id + .as_ref() + .map(|id| id.as_str()) + .unwrap_or("all") + .to_string(), + )?; + self.reconciliation + .resume(environment, library_id, cancellation) + .await + } + + async fn preview_impacts( + &self, + mut membership: LibraryMembershipPreview, + catalog: LibraryCatalog, + skill_names: Vec, + retiring: bool, + ) -> Result { + let mut impacts = Vec::with_capacity(membership.scopes.len()); + let mut evidence = Vec::with_capacity(membership.scopes.len()); + let mut planned = Vec::new(); + for context in &membership.scopes { + match self + .reconciliation + .reconciler + .plan_with_catalog(context.clone(), catalog.clone()) + .await + { + Ok(plan) => { + let skills = + membership_skill_impacts(plan.preview.as_ref(), &skill_names, retiring); + let preview_token = plan + .preview + .as_ref() + .map(|preview| preview.token.generation.clone()) + .unwrap_or_default(); + evidence.push(( + context.clone(), + preview_token, + plan.entries + .iter() + .map(|(key, action)| { + crate::application::mutation::plan::stable_digest(&( + key, + format!("{action:?}"), + )) + }) + .collect::, _>>()?, + )); + impacts.push(MembershipScopeImpact { + context: context.clone(), + skills, + }); + planned.push(plan); + } + Err(error) => { + evidence.push(( + context.clone(), + String::new(), + vec![format!("unverified:{error}")], + )); + impacts.push(MembershipScopeImpact { + context: context.clone(), + skills: skill_names + .iter() + .map(|skill_name| MembershipSkillImpact { + skill_name: skill_name.clone(), + kind: MembershipScopeImpactKind::Unverified, + }) + .collect(), + }); + } + } + } + if !conflicting_scope_plans(planned.iter()).is_empty() { + return Err(AppError::StaleTarget); + } + membership.token = crate::application::mutation::plan::stable_digest(&( + "library-membership-impact-v1", + membership.inventory_token.as_str(), + &impacts, + evidence, + ))?; + membership.impacts = impacts; + Ok(membership) + } +} + +fn membership_skill_impacts( + preview: Option<&crate::application::library_application::LibraryApplicationPreview>, + skill_names: &[String], + retiring: bool, +) -> Vec { + skill_names + .iter() + .map(|skill_name| { + let kind = match preview { + Some(preview) if preview.added_skill_names.contains(skill_name) => { + MembershipScopeImpactKind::Added + } + Some(preview) if preview.switched_skill_names.contains(skill_name) => { + if retiring { + MembershipScopeImpactKind::Fallback + } else { + MembershipScopeImpactKind::Switched + } + } + Some(preview) if preview.removed_skill_names.contains(skill_name) => { + MembershipScopeImpactKind::Removed + } + _ => MembershipScopeImpactKind::Unchanged, + }; + MembershipSkillImpact { + skill_name: skill_name.clone(), + kind, + } + }) + .collect() +} + +fn conflicting_scope_plans<'a>( + plans: impl IntoIterator, +) -> Vec { + let plans = plans.into_iter().collect::>(); + let conflicting_targets = ScopeSkillPlanner::conflicting_mutations( + plans + .iter() + .flat_map(|plan| plan.entries.iter()) + .map(|(key, action)| (key, action)), + ); + plans + .into_iter() + .filter(|plan| { + plan.entries + .iter() + .any(|(key, _)| conflicting_targets.contains(key)) + }) + .map(|plan| plan.context.clone()) + .collect() +} + +impl RetiredMemberCollector { + async fn collect( + &self, + environment: &EnvironmentRef, + selected_library: Option<&LibraryId>, + ) -> Result, AppError> { + let catalog = self.libraries.load(environment).await?; + let inventory = match self.applications.enumerate(environment).await { + Ok(inventory) => inventory, + Err(error) => { + return Ok(catalog + .libraries + .iter() + .filter(|library| selected_library.is_none_or(|id| id == &library.id)) + .flat_map(|library| { + library.retired_skills.iter().map(|retired| { + cleanup_failure( + library.id.clone(), + retired.member.name.clone(), + retired.retirement_id.as_str().to_string(), + error.clone(), + ) + }) + }) + .collect()) + } + }; + let mut results = Vec::new(); + for library in catalog + .libraries + .iter() + .filter(|library| selected_library.is_none_or(|id| id == &library.id)) + { + for retired in &library.retired_skills { + let referenced = inventory.records.iter().any(|application| { + references_member(&application.record, &library.id, &retired.member.name) + }); + if !inventory.complete || referenced { + results.push(RetiredCleanupResult { + library_id: library.id.clone(), + member_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.as_str().to_string(), + state: RetiredCleanupState::Retained, + error: None, + }); + continue; + } + let request = PurgeRetiredLibraryMemberRequest { + environment: environment.clone(), + library_id: library.id.clone(), + skill_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.clone(), + }; + match self.libraries.purge_retired(request).await { + Ok(()) => results.push(RetiredCleanupResult { + library_id: library.id.clone(), + member_name: retired.member.name.clone(), + retirement_id: retired.retirement_id.as_str().to_string(), + state: RetiredCleanupState::Purged, + error: None, + }), + Err(error) => results.push(cleanup_failure( + library.id.clone(), + retired.member.name.clone(), + retired.retirement_id.as_str().to_string(), + error, + )), + } + } + } + Ok(results) + } +} + +fn references_member( + record: &crate::application::library_application::LibraryApplicationRecord, + library_id: &LibraryId, + member_name: &str, +) -> bool { + record + .checkpoint + .members + .iter() + .chain(record.pending.iter().flat_map(|pending| { + pending + .recognized_members + .iter() + .chain(&pending.target_members) + })) + .any(|member| &member.library_id == library_id && member.member_name == member_name) +} + +fn cleanup_failure( + library_id: LibraryId, + member_name: String, + retirement_id: String, + error: AppError, +) -> RetiredCleanupResult { + RetiredCleanupResult { + library_id, + member_name, + retirement_id, + state: RetiredCleanupState::Failed, + error: Some(error), + } +} + +fn membership_preview( + environment: EnvironmentRef, + library_id: LibraryId, + inventory: &ApplicationInventory, +) -> Result { + let mut scopes = inventory + .records + .iter() + .filter(|application| references_library(&application.record, &library_id)) + .map(|application| application.context.clone()) + .collect::>(); + sort_scopes(&mut scopes); + let mut records = inventory + .records + .iter() + .map(|application| (&application.context, &application.record)) + .collect::>(); + records.sort_by(|(left, _), (right, _)| format!("{left:?}").cmp(&format!("{right:?}"))); + let mut problems = inventory + .problems + .iter() + .map(|problem| problem.storage_key.as_str()) + .collect::>(); + problems.sort_unstable(); + let token = crate::application::mutation::plan::stable_digest(&( + &records, + &problems, + inventory.complete, + ))?; + Ok(LibraryMembershipPreview { + environment, + library_id, + scopes, + impacts: Vec::new(), + inventory_complete: inventory.complete, + inventory_token: token.clone(), + token, + }) +} + +fn references_library( + record: &crate::application::library_application::LibraryApplicationRecord, + library_id: &LibraryId, +) -> bool { + record.current.ordered_library_ids.contains(library_id) + || record.pending.as_ref().is_some_and(|pending| { + pending + .before_application + .ordered_library_ids + .contains(library_id) + || pending + .target_application + .ordered_library_ids + .contains(library_id) + || pending + .recognized_members + .iter() + .chain(&pending.target_members) + .any(|member| &member.library_id == library_id) + }) + || record + .checkpoint + .members + .iter() + .any(|member| &member.library_id == library_id) +} + +fn sort_scopes(scopes: &mut [SkillLocationRef]) { + scopes.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}"))); +} + +fn membership_error_state(error: &AppError) -> MembershipScopeState { + match error { + AppError::RecoveryRequired { .. } | AppError::RestoreFailed { .. } => { + MembershipScopeState::RecoveryRequired + } + AppError::EnvironmentUnavailable { .. } + | AppError::StorageUnsupported { .. } + | AppError::PathNotFound { .. } => MembershipScopeState::Unverified, + AppError::MutationCancelled => MembershipScopeState::Cancelled, + _ => MembershipScopeState::Pending, + } +} + +fn membership_scope_state(state: LibraryApplicationSyncState) -> MembershipScopeState { + match state { + LibraryApplicationSyncState::Synced => MembershipScopeState::Synced, + LibraryApplicationSyncState::Pending => MembershipScopeState::Pending, + LibraryApplicationSyncState::Unverified => MembershipScopeState::Unverified, + LibraryApplicationSyncState::RecoveryRequired => MembershipScopeState::RecoveryRequired, + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use super::*; + use crate::application::library_application::{ + ApplicationInventory, ApplicationRegistry, LibraryApplicationFuture, + LibraryApplicationRecord, LibraryApplicationResponse, LibraryApplicationScopePlan, + LibraryApplicationSummary, VersionedApplicationRecord, + }; + use crate::application::mutation::plan::PreparedEntryAction; + use crate::application::skill_libraries::{ + CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, LibrarySkillRecord, + PurgeRetiredLibraryMemberRequest, RetirementId, SkillLibraryRecord, SkillLibraryRepository, + LIBRARY_SCHEMA_VERSION, + }; + use crate::core::mutation::CancellationSignal; + use crate::environment::runtime::{ + ExecutionBackend, PhysicalParentIdentity, PhysicalTargetKey, + }; + use crate::environment::types::{EnvironmentRef, SkillLocation, SkillLocationRef}; + use crate::error::AppError; + + #[test] + fn one_transition_handles_add_update_retire_and_reactivate() { + let mut catalog = catalog(); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(), + MembershipChangeKind::Added + ); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v2")), + ) + .unwrap(), + MembershipChangeKind::Updated + ); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(), + MembershipChangeKind::Retired + ); + assert!(catalog.libraries[0].skills.is_empty()); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + assert_eq!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v3")), + ) + .unwrap(), + MembershipChangeKind::Reactivated + ); + assert_eq!(catalog.libraries[0].skills[0].description, "v3"); + assert!(catalog.libraries[0].retired_skills.is_empty()); + } + + #[test] + fn another_identity_cannot_claim_a_retired_directory() { + let mut catalog = catalog(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "CE:Review", + MembershipChange::Upsert(record("CE:Review", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "CE:Review", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + assert!(matches!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "ce-review", + MembershipChange::Upsert(record("ce-review", "v2")), + ), + Err(AppError::Validation { .. }) + )); + } + + #[test] + fn purge_requires_the_exact_retirement_identity() { + let mut catalog = catalog(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + + assert!(matches!( + apply_membership_change( + &mut catalog, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Purge { + retirement_id: RetirementId::parse("stale"), + }, + ), + Err(AppError::StaleTarget) + )); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + } + + #[tokio::test] + async fn execute_rejects_an_expanded_scope_before_committing_library_state() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry.clone(), + Arc::new(SuccessfulReconciler), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + let preview = module + .preview(EnvironmentRef::Native, LibraryId::parse("library-1")) + .await + .unwrap(); + registry.records.lock().unwrap().push(application( + SkillLocation::Project { + project_id: "project-1".to_string(), + }, + "library-1", + )); + let committed = Arc::new(AtomicBool::new(false)); + let committed_in_future = Arc::clone(&committed); + + let result = module + .execute( + preview, + async move { + committed_in_future.store(true, Ordering::SeqCst); + Ok::<_, AppError>("saved") + }, + CancellationSignal::default(), + ) + .await; + + assert!(matches!(result, Err(AppError::StaleContext))); + assert!(!committed.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn collector_purges_only_without_application_references() { + let mut state = catalog(); + apply_membership_change( + &mut state, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Upsert(record("demo", "v1")), + ) + .unwrap(); + apply_membership_change( + &mut state, + &LibraryId::parse("library-1"), + "demo", + MembershipChange::Retire { + retirement_id: RetirementId::parse("retirement-1"), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + ) + .unwrap(); + let libraries = Arc::new(MemoryLibraries(Mutex::new(state))); + let module = reconciliation_module( + Arc::new(MemoryRegistry::new(Vec::new())), + Arc::new(SuccessfulReconciler), + libraries.clone(), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.cleanup.len(), 1); + assert_eq!(result.cleanup[0].state, RetiredCleanupState::Purged); + assert!(libraries.0.lock().unwrap().libraries[0] + .retired_skills + .is_empty()); + } + + #[tokio::test] + async fn conflicting_physical_targets_block_all_affected_scopes_before_writes() { + let registry = Arc::new(MemoryRegistry::new(vec![ + application(SkillLocation::Global, "library-1"), + application( + SkillLocation::Project { + project_id: "project-1".to_string(), + }, + "library-1", + ), + ])); + let reconciler = Arc::new(ConflictingReconciler::default()); + let module = reconciliation_module( + registry, + reconciler.clone(), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.scopes.len(), 2); + assert!(result.scopes.iter().all(|scope| { + scope.state == MembershipScopeState::Pending + && matches!(scope.error, Some(AppError::StaleTarget)) + })); + assert_eq!(reconciler.resume_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn reconciliation_preserves_the_application_recovery_state() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry, + Arc::new(FixedStateReconciler( + LibraryApplicationSyncState::RecoveryRequired, + )), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + result.scopes[0].state, + MembershipScopeState::RecoveryRequired + ); + } + + #[tokio::test] + async fn reconciliation_reports_an_attention_persistence_failure() { + let registry = Arc::new(MemoryRegistry::new(vec![application( + SkillLocation::Global, + "library-1", + )])); + let module = reconciliation_module( + registry, + Arc::new(FailingAttentionReconciler), + Arc::new(MemoryLibraries(Mutex::new(catalog()))), + ); + + let result = module + .resume( + EnvironmentRef::Native, + Some(LibraryId::parse("library-1")), + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!(result.scopes[0].state, MembershipScopeState::Unverified); + assert!(matches!(result.snapshot_error, Some(AppError::StaleTarget))); + } + + struct MemoryRegistry { + records: Mutex>, + } + + impl MemoryRegistry { + fn new(records: Vec) -> Self { + Self { + records: Mutex::new(records), + } + } + } + + impl ApplicationRegistry for MemoryRegistry { + fn load_application<'a>( + &'a self, + context: &'a SkillLocationRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + self.records + .lock() + .unwrap() + .iter() + .find(|record| &record.context == context) + .cloned() + .ok_or_else(|| AppError::PathNotFound { + path: format!("{:?}", context.scope), + }) + }) + } + + fn save_application_if<'a>( + &'a self, + _observed: &'a VersionedApplicationRecord, + _record: &'a LibraryApplicationRecord, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn enumerate<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryApplicationFuture<'a, Result> { + Box::pin(async move { + Ok(ApplicationInventory { + records: self.records.lock().unwrap().clone(), + problems: Vec::new(), + complete: true, + }) + }) + } + } + + struct SuccessfulReconciler; + + impl LibraryApplicationMembership for SuccessfulReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { + Ok(LibraryApplicationResponse { + application: LibraryApplicationSummary { + ordered_libraries: Vec::new(), + selected_agent_ids: Vec::new(), + pending: false, + sync_state: crate::application::library_application::LibraryApplicationSyncState::Synced, + }, + units: Vec::new(), + }) + }) + } + } + + struct FixedStateReconciler(LibraryApplicationSyncState); + + struct FailingAttentionReconciler; + + impl LibraryApplicationMembership for FailingAttentionReconciler { + fn plan<'a>( + &'a self, + _context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { + Err(AppError::EnvironmentUnavailable { + environment: EnvironmentRef::Native, + message: "offline".to_string(), + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + Box::pin(async { panic!("planning failure must not resume the Scope") }) + } + } + + impl LibraryApplicationMembership for FixedStateReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + Ok(LibraryApplicationScopePlan { + context, + entries: Vec::new(), + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + let state = self.0; + Box::pin(async move { + Ok(LibraryApplicationResponse { + application: LibraryApplicationSummary { + ordered_libraries: Vec::new(), + selected_agent_ids: Vec::new(), + pending: state != LibraryApplicationSyncState::Synced, + sync_state: state, + }, + units: Vec::new(), + }) + }) + } + } + + #[derive(Default)] + struct ConflictingReconciler { + resume_calls: AtomicUsize, + } + + impl LibraryApplicationMembership for ConflictingReconciler { + fn plan<'a>( + &'a self, + context: SkillLocationRef, + ) -> MembershipFuture<'a, Result> { + Box::pin(async move { + let action = match context.scope { + SkillLocation::Global => PreparedEntryAction::Keep, + SkillLocation::Project { .. } => PreparedEntryAction::Remove, + }; + Ok(LibraryApplicationScopePlan { + context, + entries: vec![(physical_target(), action)], + preview: None, + }) + }) + } + + fn plan_with_catalog<'a>( + &'a self, + context: SkillLocationRef, + _catalog: LibraryCatalog, + ) -> MembershipFuture<'a, Result> { + self.plan(context) + } + + fn record_attention<'a>( + &'a self, + _context: SkillLocationRef, + _attention: ReconciliationAttention, + ) -> MembershipFuture<'a, Result<(), AppError>> { + Box::pin(async { Ok(()) }) + } + + fn resume<'a>( + &'a self, + _context: SkillLocationRef, + _cancellation: CancellationSignal, + ) -> MembershipFuture<'a, Result> { + self.resume_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Err(AppError::StaleTarget) }) + } + } + + fn physical_target() -> PhysicalTargetKey { + PhysicalTargetKey { + backend: ExecutionBackend::NativeUnix, + physical_parent: PhysicalParentIdentity::Unix { + device: 1, + inode: 2, + }, + normalized_final_child_name: "demo".to_string(), + } + } + + struct MemoryLibraries(Mutex); + + fn reconciliation_module( + applications: Arc, + reconciler: Arc, + libraries: Arc, + ) -> MembershipReconciliationModule { + MembershipReconciliationModule::new(applications, reconciler, libraries) + } + + impl SkillLibraryRepository for MemoryLibraries { + fn resolve_collection<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result> + { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn load<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryFuture<'a, Result> { + Box::pin(async move { Ok(self.0.lock().unwrap().clone()) }) + } + + fn save<'a>( + &'a self, + _environment: &'a EnvironmentRef, + catalog: &'a LibraryCatalog, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + *self.0.lock().unwrap() = catalog.clone(); + Ok(()) + }) + } + + fn commit_member<'a>( + &'a self, + _request: CommitLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + apply_membership_change( + &mut self.0.lock().unwrap(), + &request.library_id, + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + Ok(()) + }) + } + + fn delete_library<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + + fn read_skill_content<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + _skill_name: &'a str, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { Err(AppError::StaleTarget) }) + } + } + + fn application(scope: SkillLocation, library_id: &str) -> VersionedApplicationRecord { + let mut record = LibraryApplicationRecord::empty(); + record.current.ordered_library_ids = vec![LibraryId::parse(library_id)]; + VersionedApplicationRecord::in_memory( + SkillLocationRef { + environment: EnvironmentRef::Native, + scope, + }, + record, + ) + } + + fn catalog() -> LibraryCatalog { + LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-1"), + name: "Library".to_string(), + skills: Vec::new(), + retired_skills: Vec::new(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + } + } + + fn record(name: &str, version: &str) -> LibrarySkillRecord { + LibrarySkillRecord { + name: name.to_string(), + description: version.to_string(), + source_record: json!({ "sourceType": "local", "source": version }), + content_manifest_hash: format!("manifest-{version}"), + updated_at: Some("2026-09-06T00:00:00Z".to_string()), + extra: serde_json::Map::new(), + } + } +} diff --git a/src-tauri/src/application/library_membership/state.rs b/src-tauri/src/application/library_membership/state.rs new file mode 100644 index 00000000..66025b02 --- /dev/null +++ b/src-tauri/src/application/library_membership/state.rs @@ -0,0 +1,146 @@ +use crate::application::installed_skill_resolver::InstalledSkillResolver; +use crate::application::skill_libraries::{ + merge_unknown_source_fields, validate_catalog, LibraryCatalog, LibraryId, LibrarySkillRecord, + RetiredLibrarySkillRecord, RetirementId, +}; +use crate::error::AppError; + +#[derive(Debug, Clone)] +pub(crate) enum MembershipChange { + Upsert(LibrarySkillRecord), + Retire { + retirement_id: RetirementId, + retired_at: String, + }, + Purge { + retirement_id: RetirementId, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MembershipChangeKind { + Added, + Updated, + Retired, + Reactivated, + Purged, +} + +pub(crate) fn apply_membership_change( + catalog: &mut LibraryCatalog, + library_id: &LibraryId, + member_name: &str, + change: MembershipChange, +) -> Result { + validate_catalog(catalog)?; + let library = catalog + .libraries + .iter_mut() + .find(|library| &library.id == library_id) + .ok_or_else(|| AppError::PathNotFound { + path: library_id.as_str().to_string(), + })?; + let result = match change { + MembershipChange::Upsert(mut replacement) => { + if replacement.name != member_name { + return Err(AppError::StaleTarget); + } + let directory = InstalledSkillResolver::install_dir_name(member_name)?; + for active in &library.skills { + if active.name != member_name + && InstalledSkillResolver::install_dir_name(&active.name)? == directory + { + return Err(name_conflict()); + } + } + for retired in &library.retired_skills { + if retired.member.name != member_name + && InstalledSkillResolver::install_dir_name(&retired.member.name)? == directory + { + return Err(name_conflict()); + } + } + if let Some(current) = library + .skills + .iter_mut() + .find(|member| member.name == member_name) + { + preserve_unknown_fields(&mut replacement, current); + *current = replacement; + MembershipChangeKind::Updated + } else if let Some(index) = library + .retired_skills + .iter() + .position(|retired| retired.member.name == member_name) + { + let retired = library.retired_skills.remove(index); + preserve_unknown_fields(&mut replacement, &retired.member); + library.skills.push(replacement); + library + .skills + .sort_by(|left, right| left.name.cmp(&right.name)); + MembershipChangeKind::Reactivated + } else { + library.skills.push(replacement); + library + .skills + .sort_by(|left, right| left.name.cmp(&right.name)); + MembershipChangeKind::Added + } + } + MembershipChange::Retire { + retirement_id, + retired_at, + } => { + if retirement_id.as_str().is_empty() || retired_at.is_empty() { + return Err(AppError::StaleTarget); + } + let index = library + .skills + .iter() + .position(|member| member.name == member_name) + .ok_or_else(|| AppError::PathNotFound { + path: member_name.to_string(), + })?; + let member = library.skills.remove(index); + library.retired_skills.push(RetiredLibrarySkillRecord { + retirement_id, + member, + retired_at, + extra: serde_json::Map::new(), + }); + library + .retired_skills + .sort_by(|left, right| left.member.name.cmp(&right.member.name)); + MembershipChangeKind::Retired + } + MembershipChange::Purge { retirement_id } => { + let index = library + .retired_skills + .iter() + .position(|retired| retired.member.name == member_name) + .ok_or_else(|| AppError::PathNotFound { + path: member_name.to_string(), + })?; + if library.retired_skills[index].retirement_id != retirement_id { + return Err(AppError::StaleTarget); + } + library.retired_skills.remove(index); + MembershipChangeKind::Purged + } + }; + validate_catalog(catalog)?; + Ok(result) +} + +fn preserve_unknown_fields(replacement: &mut LibrarySkillRecord, current: &LibrarySkillRecord) { + replacement.extra = current.extra.clone(); + merge_unknown_source_fields(&mut replacement.source_record, ¤t.source_record); +} + +fn name_conflict() -> AppError { + AppError::Validation { + field: Some("skillName".to_string()), + message: "Skill name conflicts with an active or retired Library member".to_string(), + } +} diff --git a/src-tauri/src/application/library_update.rs b/src-tauri/src/application/library_update.rs index 072672be..0627e44f 100644 --- a/src-tauri/src/application/library_update.rs +++ b/src-tauri/src/application/library_update.rs @@ -6,6 +6,7 @@ use specta::Type; #[cfg(test)] use crate::application::collection_records::SkillSelection; +use crate::application::library_membership::LibraryMembershipOutcome; use crate::application::mutation::plan::stable_digest; use crate::application::mutation::result::ErrorReport; use crate::application::payload_session::{ @@ -67,7 +68,8 @@ pub struct LibraryUpdateResponse { pub sources: Vec, pub results: Vec, pub outcome: UpdateOutcome, - pub library: SkillLibraryDetail, + pub library: Option, + pub membership: LibraryMembershipOutcome, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -161,7 +163,7 @@ pub struct ExecuteLibraryUpdateRequest { #[specta(tag = "status", rename_all = "camelCase")] pub enum LibraryUpdateExecutionOutcome { Completed { - response: LibraryUpdateResponse, + response: Box, }, ConfirmationRequired { token: LibraryUpdatePreviewToken, @@ -300,10 +302,11 @@ where Ok(acquisitions) => continuation_from_acquisitions(acquisitions), Err(AppError::MutationCancelled) => { mark_cancelled(&request.skill_names, &mut results); - return self - .response(request, &[], results) - .await - .map(|response| LibraryUpdateExecutionOutcome::Completed { response }); + return self.response(request, &[], results).await.map(|response| { + LibraryUpdateExecutionOutcome::Completed { + response: Box::new(response), + } + }); } Err(error) => return Err(error), }, @@ -607,7 +610,9 @@ where } self.response(request, &continuation.sources, results) .await - .map(|response| LibraryUpdateExecutionOutcome::Completed { response }) + .map(|response| LibraryUpdateExecutionOutcome::Completed { + response: Box::new(response), + }) } async fn response( @@ -643,14 +648,23 @@ where }) .collect(); let outcome = library_update_outcome(&ordered); + let snapshot = self + .libraries + .detail(request.environment.clone(), request.library_id.clone()) + .await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; Ok(LibraryUpdateResponse { sources: source_results, results: ordered, outcome, - library: self - .libraries - .detail(request.environment.clone(), request.library_id.clone()) - .await?, + library, + membership: LibraryMembershipOutcome { + snapshot_error, + ..LibraryMembershipOutcome::default() + }, }) } } @@ -919,8 +933,9 @@ mod tests { PayloadPlanningMetadata, PayloadSessionLimits, }; use crate::application::skill_libraries::{ - LibraryCatalog, LibraryId, LibrarySkillRecord, LibrarySkillSourceRecord, - SkillLibraryRecord, SkillLibraryRepository, LIBRARY_SCHEMA_VERSION, + LibraryCatalog, LibraryFuture, LibraryId, LibrarySkillRecord, LibrarySkillSourceRecord, + LibraryUsage, LibraryUsageProvider, LibraryUsageSnapshot, SkillLibraryRecord, + SkillLibraryRepository, LIBRARY_SCHEMA_VERSION, }; use crate::application::skill_paths::{ ContentRevision, RootResolutionRevision, TargetRevision, @@ -948,6 +963,35 @@ mod tests { names: Vec, } + struct FailingSnapshotUsages; + + impl LibraryUsageProvider for FailingSnapshotUsages { + fn usages<'a>( + &'a self, + _environment: &'a EnvironmentRef, + _library_id: &'a LibraryId, + ) -> LibraryFuture<'a, Result, AppError>> { + Box::pin(async { + Err(AppError::Io { + message: "snapshot unavailable".to_string(), + }) + }) + } + + fn usage_projection<'a>( + &'a self, + _environment: &'a EnvironmentRef, + ) -> LibraryFuture<'a, Result> { + Box::pin(async { + Ok(LibraryUsageSnapshot { + projections: Vec::new(), + inventory_complete: true, + problem_count: 0, + }) + }) + } + } + impl FixedSubjects { fn snapshot( &self, @@ -1259,7 +1303,7 @@ mod tests { ) .await? { - LibraryUpdateExecutionOutcome::Completed { response } => Ok(response), + LibraryUpdateExecutionOutcome::Completed { response } => Ok(*response), LibraryUpdateExecutionOutcome::ConfirmationRequired { .. } => { Err(AppError::StaleContext) } @@ -1500,6 +1544,8 @@ mod tests { assert_eq!( response .library + .as_ref() + .unwrap() .skills .iter() .map(|skill| skill.description.as_str()) @@ -1513,6 +1559,52 @@ mod tests { .all(|skill| source_revision(&skill.source_record).as_deref() == Some("new"))); } + #[tokio::test] + async fn committed_update_survives_a_failed_detail_snapshot() { + let Fixture { + _temp, + repository, + library_id, + manager, + subjects: _, + source, + } = fixture(&["alpha"], None).await; + let service = LibraryUpdateService::new( + manager, + LibraryUpdateSubjectProvider::new(repository.clone(), targets()), + source, + targets(), + Arc::new(SkillLibraryModule::with_usages( + repository.clone(), + Arc::new(FailingSnapshotUsages), + )), + ); + + let response = execute_completed( + &service, + UpdateLibrarySkillsRequest { + environment: EnvironmentRef::Native, + library_id, + skill_names: vec!["alpha".to_string()], + }, + CancellationSignal::default(), + ) + .await + .unwrap(); + + assert_eq!( + response.results[0].status, + LibraryUpdateSkillStatus::Succeeded + ); + assert!(response.library.is_none()); + assert!(matches!( + response.membership.snapshot_error, + Some(AppError::Io { .. }) + )); + let saved = repository.load(&EnvironmentRef::Native).await.unwrap(); + assert_eq!(saved.libraries[0].skills[0].description, "alpha updated"); + } + #[tokio::test] async fn redirected_source_requires_confirmation_before_any_library_write() { let Fixture { @@ -1584,7 +1676,10 @@ mod tests { response.results[0].status, LibraryUpdateSkillStatus::Succeeded ); - assert_eq!(response.library.skills[0].description, "alpha updated"); + assert_eq!( + response.library.as_ref().unwrap().skills[0].description, + "alpha updated" + ); } #[tokio::test] @@ -1628,7 +1723,10 @@ mod tests { response.results[0].status, LibraryUpdateSkillStatus::NameChanged ); - assert_eq!(response.library.skills[0].description, "alpha old"); + assert_eq!( + response.library.as_ref().unwrap().skills[0].description, + "alpha old" + ); } #[tokio::test] @@ -1673,8 +1771,14 @@ mod tests { response.results[1].status, LibraryUpdateSkillStatus::Succeeded ); - assert_eq!(response.library.skills[0].description, "alpha old"); - assert_eq!(response.library.skills[1].description, "beta updated"); + assert_eq!( + response.library.as_ref().unwrap().skills[0].description, + "alpha old" + ); + assert_eq!( + response.library.as_ref().unwrap().skills[1].description, + "beta updated" + ); } fn payload_manager() -> PayloadSessionManager { @@ -1723,6 +1827,7 @@ mod tests { extra: serde_json::Map::new(), }) .collect(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 64d8ea3a..fd275918 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -23,6 +23,7 @@ pub mod installed_skill_resolver; pub(crate) mod library_agent_placements; pub mod library_application; pub mod library_candidates; +pub mod library_membership; pub mod library_update; pub mod manage_agents; pub mod mutation; diff --git a/src-tauri/src/application/skill_libraries.rs b/src-tauri/src/application/skill_libraries.rs index defe87ba..9c9228bb 100644 --- a/src-tauri/src/application/skill_libraries.rs +++ b/src-tauri/src/application/skill_libraries.rs @@ -8,8 +8,9 @@ use specta::Type; use crate::application::collection_records::{ CollectionRecordReader, DocumentRevision, LibraryCatalogRecordReader, SourceRecordRevision, }; -#[cfg(test)] use crate::application::installed_skill_resolver::InstalledSkillResolver; +use crate::application::library_membership::LibraryMembershipOutcome; +use crate::application::library_membership::LibraryMembershipPreview; use crate::application::mutation::plan::stable_digest; use crate::application::payload_session::{ AcquiredPayloadHandle, DiscoverySessionHandle, PayloadSessionManager, @@ -23,7 +24,7 @@ use crate::core::skill::parse_skill_md_content; use crate::core::skill_payload::{PayloadEntryKind, SkillPayload}; use crate::environment::content_manifest::ContentManifestReader; use crate::environment::planning::{TargetEntryKind, TargetFactResolver}; -use crate::environment::types::{EnvironmentRef, SkillLocationRef}; +use crate::environment::types::{same_environment_identity, EnvironmentRef, SkillLocationRef}; use crate::error::AppError; pub(crate) const LIBRARY_SCHEMA_VERSION: u32 = 3; @@ -45,6 +46,25 @@ impl LibraryId { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)] +#[serde(transparent)] +pub struct RetirementId(String); + +impl RetirementId { + pub(crate) fn new() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + #[cfg(test)] + pub(crate) fn parse(value: impl Into) -> Self { + Self(value.into()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -86,8 +106,8 @@ pub struct SkillLibraryDetail { /// 某个 Skill 位置引用当前对象的方式。 /// -/// 生效与锁定是两件事:`Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有 -/// 未完成的应用操作引用它、尚未确认生效。两者的并集才是成员锁定的判定依据。 +/// `Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有未完成的应用操作 +/// 引用它、尚未确认生效。两者的并集用于整库删除保护和使用状态展示。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -118,6 +138,13 @@ pub struct LibraryUsageProjection { pub pending_count: u32, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryUsageSnapshot { + pub projections: Vec, + pub inventory_complete: bool, + pub problem_count: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -170,6 +197,25 @@ pub struct LibraryAddPreview { pub token: LibraryAddPreviewToken, pub skills: Vec, pub redirected_download_host: Option, + pub membership: LibraryMembershipPreview, +} + +impl LibraryAddPreview { + pub(crate) fn bind_membership( + &mut self, + request: &PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, + ) -> Result<(), AppError> { + self.token.generation = library_add_preview_generation( + request, + &self.redirected_download_host, + &self.token.context_revision, + &self.token.skill_revisions, + &membership, + )?; + self.membership = membership; + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -178,6 +224,7 @@ pub struct LibraryAddPreview { pub struct ExecuteAddLibrarySkillsRequest { pub request: PreviewAddLibrarySkillsRequest, pub expected_token: LibraryAddPreviewToken, + pub membership: LibraryMembershipPreview, pub acknowledge_redirect: bool, } @@ -205,7 +252,14 @@ pub struct LibraryAddSkillResult { #[specta(rename_all = "camelCase")] pub struct LibraryAddResponse { pub results: Vec, - pub library: SkillLibraryDetail, + pub library: Option, + pub membership: LibraryMembershipOutcome, +} + +pub(crate) struct LibraryAddCommitResponse { + pub(crate) results: Vec, + pub(crate) library: Option, + pub(crate) snapshot_error: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -226,6 +280,70 @@ pub struct RemoveLibrarySkillRequest { pub skill_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryRetirePreview { + pub skill_name: String, + pub token: String, + pub membership: LibraryMembershipPreview, +} + +pub(crate) struct PreparedLibraryRetire { + skill_name: String, + base_token: String, +} + +impl PreparedLibraryRetire { + pub(crate) fn skill_name(&self) -> &str { + &self.skill_name + } + + pub(crate) fn bind( + self, + membership: LibraryMembershipPreview, + ) -> Result { + Ok(LibraryRetirePreview { + skill_name: self.skill_name, + token: bound_retire_preview_token(&self.base_token, &membership)?, + membership, + }) + } +} + +fn bound_retire_preview_token( + base_token: &str, + membership: &LibraryMembershipPreview, +) -> Result { + stable_digest(&( + "library-retire-preview", + base_token, + membership.token.as_str(), + )) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct ExecuteRetireLibrarySkillRequest { + pub request: RemoveLibrarySkillRequest, + pub expected_token: String, + pub membership: LibraryMembershipPreview, +} + +#[derive(Debug, Clone, Serialize, Type)] +#[serde(rename_all = "camelCase")] +#[specta(rename_all = "camelCase")] +pub struct LibraryRetireResponse { + pub library: Option, + pub membership: LibraryMembershipOutcome, +} + +pub(crate) struct LibraryRetireCommitResponse { + pub(crate) library: Option, + pub(crate) snapshot_error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] #[specta(rename_all = "camelCase")] @@ -235,6 +353,8 @@ pub struct LibraryWorkspaceSnapshot { /// catalog 内容的摘要。应用关系不参与该摘要,页面重新进入时自行拉取最新投影。 pub revision: String, pub usage_projection: Vec, + pub usage_inventory_complete: bool, + pub usage_inventory_problem_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -252,6 +372,17 @@ pub(crate) struct SkillLibraryRecord { pub(crate) id: LibraryId, pub(crate) name: String, pub(crate) skills: Vec, + pub(crate) retired_skills: Vec, + #[serde(flatten)] + pub(crate) extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RetiredLibrarySkillRecord { + pub(crate) retirement_id: RetirementId, + pub(crate) member: LibrarySkillRecord, + pub(crate) retired_at: String, #[serde(flatten)] pub(crate) extra: serde_json::Map, } @@ -311,7 +442,10 @@ pub(crate) enum LibraryMemberMutation { content: Box, record: Box, }, - Delete, + Retire { + retirement_id: RetirementId, + retired_at: String, + }, } #[derive(Debug, Clone)] @@ -323,6 +457,14 @@ pub(crate) struct CommitLibraryMemberRequest { pub(crate) mutation: LibraryMemberMutation, } +#[derive(Debug, Clone)] +pub(crate) struct PurgeRetiredLibraryMemberRequest { + pub(crate) environment: EnvironmentRef, + pub(crate) library_id: LibraryId, + pub(crate) skill_name: String, + pub(crate) retirement_id: RetirementId, +} + impl Default for LibraryCatalog { fn default() -> Self { Self { @@ -356,6 +498,11 @@ pub trait SkillLibraryRepository: Send + Sync { request: CommitLibraryMemberRequest, ) -> LibraryFuture<'a, Result<(), AppError>>; + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>>; + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -373,8 +520,8 @@ pub trait SkillLibraryRepository: Send + Sync { pub trait LibraryUsageProvider: Send + Sync { /// 返回引用该库的全部 Skill 位置,包含已确认生效和仅被未完成操作引用两种状态。 /// - /// 成员锁定依赖这个并集语义:任何卷入未完成操作的库都必须锁住。展示层需要区分状态时 - /// 读取每一项的 `state`,不要改变本方法的收集范围。 + /// 整库删除保护依赖这个并集语义:任何卷入未完成操作的库都不能删除。展示层需要区分 + /// 状态时读取每一项的 `state`,不要改变本方法的收集范围。 fn usages<'a>( &'a self, environment: &'a EnvironmentRef, @@ -385,7 +532,7 @@ pub trait LibraryUsageProvider: Send + Sync { fn usage_projection<'a>( &'a self, environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>>; + ) -> LibraryFuture<'a, Result>; fn agent_usages<'a>( &'a self, @@ -412,8 +559,14 @@ impl LibraryUsageProvider for EmptyLibraryUsageProvider { fn usage_projection<'a>( &'a self, _environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) + ) -> LibraryFuture<'a, Result> { + Box::pin(async { + Ok(LibraryUsageSnapshot { + projections: Vec::new(), + inventory_complete: true, + problem_count: 0, + }) + }) } } @@ -453,8 +606,8 @@ impl SkillLibraryModule { environment: EnvironmentRef, catalog: LibraryCatalog, ) -> Result { - let usage_projection = self.usages.usage_projection(&environment).await?; - workspace_snapshot(environment, catalog, usage_projection) + let usage = self.usages.usage_projection(&environment).await?; + workspace_snapshot(environment, catalog, usage) } pub async fn create( @@ -470,6 +623,7 @@ impl SkillLibraryModule { id: LibraryId(uuid::Uuid::new_v4().simple().to_string()), name, skills: Vec::new(), + retired_skills: Vec::new(), extra: serde_json::Map::new(), }); self.repository.save(&environment, &catalog).await?; @@ -515,19 +669,38 @@ impl SkillLibraryModule { Ok(detail_from_record(library, usages)) } + #[cfg(test)] pub async fn preview_add_skills( &self, payloads: &PayloadSessionManager, targets: &T, request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { Ok(self - .build_add_skills(payloads, targets, request, true) + .preview_add_skills_with_catalog(payloads, targets, request, membership) .await? - .preview) + .0) + } + + pub(crate) async fn preview_add_skills_with_catalog( + &self, + payloads: &PayloadSessionManager, + targets: &T, + request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, + ) -> Result<(LibraryAddPreview, LibraryCatalog), AppError> + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + let built = self + .build_add_skills(payloads, targets, request, membership, true) + .await?; + let projected = projected_add_catalog(&built)?; + Ok((built.preview, projected)) } pub async fn execute_add_skills( @@ -535,18 +708,25 @@ impl SkillLibraryModule { payloads: &PayloadSessionManager, targets: &T, execution: ExecuteAddLibrarySkillsRequest, - ) -> Result + ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { let built = self - .build_add_skills(payloads, targets, execution.request, false) + .build_add_skills( + payloads, + targets, + execution.request, + execution.membership, + false, + ) .await?; let expected_generation = library_add_preview_generation( &built.request, &execution.expected_token.redirected_download_host, &execution.expected_token.context_revision, &execution.expected_token.skill_revisions, + &built.preview.membership, )?; if expected_generation != execution.expected_token.generation { return Err(AppError::StaleContext); @@ -652,13 +832,21 @@ impl SkillLibraryModule { error: None, }); } - let library = self + let snapshot = self .detail( built.request.environment.clone(), built.request.library_id.clone(), ) - .await?; - Ok(LibraryAddResponse { results, library }) + .await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; + Ok(LibraryAddCommitResponse { + results, + library, + snapshot_error, + }) } async fn build_add_skills( @@ -666,20 +854,34 @@ impl SkillLibraryModule { payloads: &PayloadSessionManager, targets: &T, request: PreviewAddLibrarySkillsRequest, + membership: LibraryMembershipPreview, reject_conflicts: bool, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { + validate_membership_target(&membership, &request.environment, &request.library_id)?; if request.skills.is_empty() || request.discovery_session.environment != request.environment { return Err(AppError::StalePayload); } let usage_revision = self - .unapplied_usage_revision(&request.environment, &request.library_id) + .usage_revision(&request.environment, &request.library_id) .await?; let catalog = self.repository.load(&request.environment).await?; validate_catalog(&catalog)?; + let library = catalog + .libraries + .iter() + .find(|library| library.id == request.library_id) + .ok_or_else(|| AppError::PathNotFound { + path: request.library_id.as_str().to_string(), + })?; + let retired = library + .retired_skills + .iter() + .map(|retired| (retired.member.name.as_str(), &retired.member)) + .collect::>(); let collection = self .repository .resolve_collection(&request.environment, &request.library_id) @@ -747,8 +949,19 @@ impl SkillLibraryModule { .iter() .find(|record| record.skill_name == change.skill_name) .is_some_and(|record| record.projection.metadata().is_some()); + let reactivating = retired + .get(change.skill_name.as_str()) + .is_some_and(|member| { + change.canonical_target.target.entry_kind == TargetEntryKind::Directory + && change + .canonical_target + .content_revision + .manifest_hash() + .is_some_and(|hash| hash.as_str() == member.content_manifest_hash) + }); if existing_record - || change.canonical_target.target.entry_kind != TargetEntryKind::Missing + || (change.canonical_target.target.entry_kind != TargetEntryKind::Missing + && !reactivating) { return Err(AppError::Validation { field: Some("skillName".to_string()), @@ -792,6 +1005,7 @@ impl SkillLibraryModule { &redirected_download_host, &context_revision, &skill_revisions, + &membership, )?; let preview = LibraryAddPreview { token: LibraryAddPreviewToken { @@ -812,11 +1026,13 @@ impl SkillLibraryModule { }) .collect(), redirected_download_host, + membership, }; Ok(BuiltLibraryAdd { request, items, preview, + catalog, }) } @@ -828,7 +1044,7 @@ impl SkillLibraryModule { prepared: ReadyUpdatePayload, ) -> Result<(), AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { let current_collection = self .repository @@ -910,16 +1126,87 @@ impl SkillLibraryModule { .await } - pub async fn remove_skill( + pub(crate) async fn prepare_retire_skill_with_catalog( &self, targets: &T, request: RemoveLibrarySkillRequest, - ) -> Result + membership: LibraryMembershipPreview, + ) -> Result<(PreparedLibraryRetire, LibraryCatalog), AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { - self.ensure_not_applied(&request.environment, &request.library_id) + let observed = self + .observe_retire_skill(targets, &request, &membership) .await?; + let mut projected = observed.catalog; + crate::application::library_membership::apply_membership_change( + &mut projected, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Retire { + retirement_id: RetirementId("preview".to_string()), + retired_at: "preview".to_string(), + }, + )?; + Ok(( + PreparedLibraryRetire { + skill_name: request.skill_name, + base_token: observed.token, + }, + projected, + )) + } + + pub async fn retire_skill( + &self, + targets: &T, + execution: ExecuteRetireLibrarySkillRequest, + ) -> Result + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + let observed = self + .observe_retire_skill(targets, &execution.request, &execution.membership) + .await?; + if bound_retire_preview_token(&observed.token, &execution.membership)? + != execution.expected_token + { + return Err(AppError::StaleContext); + } + let request = execution.request; + self.repository + .commit_member(CommitLibraryMemberRequest { + environment: request.environment.clone(), + library_id: request.library_id.clone(), + skill_name: request.skill_name.clone(), + expected: observed.expected, + mutation: LibraryMemberMutation::Retire { + retirement_id: RetirementId::new(), + retired_at: committed_at(), + }, + }) + .await?; + let snapshot = self.detail(request.environment, request.library_id).await; + let (library, snapshot_error) = match snapshot { + Ok(library) => (Some(library), None), + Err(error) => (None, Some(error)), + }; + Ok(LibraryRetireCommitResponse { + library, + snapshot_error, + }) + } + + async fn observe_retire_skill( + &self, + targets: &T, + request: &RemoveLibrarySkillRequest, + membership: &LibraryMembershipPreview, + ) -> Result + where + T: TargetFactResolver + ContentManifestReader + ?Sized, + { + validate_membership_target(membership, &request.environment, &request.library_id)?; let collection = self .repository .resolve_collection(&request.environment, &request.library_id) @@ -935,7 +1222,7 @@ impl SkillLibraryModule { crate::application::collection_records::RecordProjection::Missing ) { return Err(AppError::PathNotFound { - path: request.skill_name, + path: request.skill_name.clone(), }); } let target = SkillPathObserver::resolve_skill_targets( @@ -949,21 +1236,25 @@ impl SkillLibraryModule { .await? .pop() .ok_or(AppError::StaleTarget)?; - self.repository - .commit_member(CommitLibraryMemberRequest { - environment: request.environment.clone(), - library_id: request.library_id.clone(), - skill_name: request.skill_name.clone(), - expected: LibraryMemberCommitExpectation { - document_revision: snapshot.document_revision, - source_record_revision: snapshot.records[0].source_record_revision.clone(), - target_revision: target.target_revision, - content_revision: target.content_revision, - }, - mutation: LibraryMemberMutation::Delete, - }) - .await?; - self.detail(request.environment, request.library_id).await + let expected = LibraryMemberCommitExpectation { + document_revision: snapshot.document_revision, + source_record_revision: snapshot.records[0].source_record_revision.clone(), + target_revision: target.target_revision, + content_revision: target.content_revision, + }; + let token = stable_digest(&( + "library-retire-evidence", + request, + &expected.document_revision, + expected.source_record_revision.as_str(), + &expected.target_revision, + &expected.content_revision, + ))?; + Ok(ObservedLibraryRetire { + expected, + token, + catalog, + }) } pub async fn delete( @@ -971,39 +1262,44 @@ impl SkillLibraryModule { environment: EnvironmentRef, library_id: LibraryId, ) -> Result { - self.ensure_not_applied(&environment, &library_id).await?; + let usage = self.ensure_not_applied(&environment, &library_id).await?; let catalog = self .repository .delete_library(&environment, &library_id) .await?; - self.snapshot_with_usages(environment, catalog).await + workspace_snapshot(environment, catalog, usage) } async fn ensure_not_applied( &self, environment: &EnvironmentRef, library_id: &LibraryId, - ) -> Result<(), AppError> { - self.unapplied_usage_revision(environment, library_id) - .await - .map(|_| ()) + ) -> Result { + let usage = self.usages.usage_projection(environment).await?; + if !usage.inventory_complete { + return Err(AppError::ConfigurationCorrupted { + message: "Skill Library application inventory is incomplete".to_string(), + }); + } + if usage.projections.iter().any(|projection| { + &projection.library_id == library_id + && (projection.confirmed_count > 0 || projection.pending_count > 0) + }) { + return Err(AppError::Validation { + field: Some("libraryId".to_string()), + message: "Skill Library cannot be deleted while it is applied".to_string(), + }); + } + Ok(usage) } - async fn unapplied_usage_revision( + async fn usage_revision( &self, environment: &EnvironmentRef, library_id: &LibraryId, ) -> Result { let usages = self.usages.usages(environment, library_id).await?; - if usages.is_empty() { - stable_digest(&("library-add-usages-v1", usages)) - } else { - Err(AppError::Validation { - field: Some("libraryId".to_string()), - message: "Skill Library membership cannot change while the Library is applied" - .to_string(), - }) - } + stable_digest(&("library-membership-usages-v1", usages)) } async fn observe_add_skill( @@ -1015,11 +1311,9 @@ impl SkillLibraryModule { install_dir_name: &str, ) -> Result where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { - let usage_revision = self - .unapplied_usage_revision(environment, library_id) - .await?; + let usage_revision = self.usage_revision(environment, library_id).await?; let collection = self .repository .resolve_collection(environment, library_id) @@ -1166,6 +1460,26 @@ struct BuiltLibraryAdd { request: PreviewAddLibrarySkillsRequest, items: Vec, preview: LibraryAddPreview, + catalog: LibraryCatalog, +} + +fn projected_add_catalog(built: &BuiltLibraryAdd) -> Result { + let mut catalog = built.catalog.clone(); + for change in built.items.iter().filter_map(|item| match item { + BuiltLibraryAddItem::Prepared(change) => Some(change.as_ref()), + BuiltLibraryAddItem::Failed(_) => None, + }) { + let frontmatter = payload_frontmatter(change.payload.payload())?; + let mut record = library_record(&change.payload, frontmatter.description)?; + record.updated_at = None; + crate::application::library_membership::apply_membership_change( + &mut catalog, + &built.request.library_id, + &change.skill_name, + crate::application::library_membership::MembershipChange::Upsert(record), + )?; + } + Ok(catalog) } enum BuiltLibraryAddItem { @@ -1215,7 +1529,7 @@ async fn prepare_library_add_targets( cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if payloads.is_empty() { return Err(AppError::Validation { @@ -1277,11 +1591,18 @@ struct ObservedLibraryAddSkill { expected: LibraryMemberCommitExpectation, } +struct ObservedLibraryRetire { + expected: LibraryMemberCommitExpectation, + token: String, + catalog: LibraryCatalog, +} + fn library_add_preview_generation( request: &PreviewAddLibrarySkillsRequest, redirected_download_host: &Option, context_revision: &str, skill_revisions: &[LibraryAddSkillRevision], + membership: &LibraryMembershipPreview, ) -> Result { stable_digest(&( "library-add-preview-v2", @@ -1289,6 +1610,7 @@ fn library_add_preview_generation( redirected_download_host, context_revision, skill_revisions, + membership, )) } @@ -1350,6 +1672,20 @@ fn committed_at() -> String { .to_string() } +fn validate_membership_target( + membership: &LibraryMembershipPreview, + environment: &EnvironmentRef, + library_id: &LibraryId, +) -> Result<(), AppError> { + if &membership.library_id == library_id + && same_environment_identity(&membership.environment, environment) + { + Ok(()) + } else { + Err(AppError::StaleContext) + } +} + fn library_source_record( source: &crate::application::skill_changes::NormalizedSkillSource, ) -> LibrarySkillSourceRecord { @@ -1395,24 +1731,10 @@ pub(crate) fn merge_unknown_source_fields( } } -#[cfg(test)] -fn find_library_mut<'a>( - catalog: &'a mut LibraryCatalog, - id: &LibraryId, -) -> Result<&'a mut SkillLibraryRecord, AppError> { - catalog - .libraries - .iter_mut() - .find(|library| &library.id == id) - .ok_or_else(|| AppError::PathNotFound { - path: id.as_str().to_string(), - }) -} - fn workspace_snapshot( environment: EnvironmentRef, catalog: LibraryCatalog, - usage_projection: Vec, + usage: LibraryUsageSnapshot, ) -> Result { validate_catalog(&catalog)?; let revision = crate::application::mutation::plan::stable_digest(&catalog)?; @@ -1429,7 +1751,9 @@ fn workspace_snapshot( environment, libraries, revision, - usage_projection, + usage_projection: usage.projections, + usage_inventory_complete: usage.inventory_complete, + usage_inventory_problem_count: usage.problem_count, }) } @@ -1481,9 +1805,46 @@ pub(crate) fn validate_catalog(catalog: &LibraryCatalog) -> Result<(), AppError> ), }); } + let invalid = |message: &str| AppError::ConfigurationCorrupted { + message: message.to_string(), + }; + let mut library_ids = std::collections::BTreeSet::new(); + let mut retirement_ids = std::collections::BTreeSet::new(); + for library in &catalog.libraries { + if !library_ids.insert(library.id.clone()) + || library.name.trim().is_empty() + || !valid_library_storage_component(library.id.as_str()) + { + return Err(invalid("invalid Skill Library identity")); + } + let mut member_directories = std::collections::BTreeSet::new(); + for member in &library.skills { + let directory = InstalledSkillResolver::install_dir_name(&member.name) + .map_err(|_| invalid("invalid active Skill Library member name"))?; + if !member_directories.insert(directory) || member.content_manifest_hash.is_empty() { + return Err(invalid("duplicate active Skill Library member directory")); + } + } + for retired in &library.retired_skills { + let directory = InstalledSkillResolver::install_dir_name(&retired.member.name) + .map_err(|_| invalid("invalid retired Skill Library member name"))?; + if retired.retirement_id.as_str().is_empty() + || retired.retired_at.is_empty() + || !retirement_ids.insert(retired.retirement_id.as_str()) + || !member_directories.insert(directory) + || retired.member.content_manifest_hash.is_empty() + { + return Err(invalid("invalid retired Skill Library member")); + } + } + } Ok(()) } +fn valid_library_storage_component(value: &str) -> bool { + !value.is_empty() && !matches!(value, "." | "..") && !value.contains(['/', '\\', '\0']) +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -1508,8 +1869,31 @@ mod tests { fn usage_projection<'a>( &'a self, _environment: &'a EnvironmentRef, - ) -> LibraryFuture<'a, Result, AppError>> { - Box::pin(async { Ok(Vec::new()) }) + ) -> LibraryFuture<'a, Result> { + Box::pin(async move { + let confirmed_count = self + .0 + .iter() + .filter(|usage| usage.state == LibraryUsageState::Confirmed) + .count() as u32; + let pending_count = self + .0 + .iter() + .filter(|usage| usage.state == LibraryUsageState::PendingAdjustment) + .count() as u32; + Ok(LibraryUsageSnapshot { + projections: (!self.0.is_empty()) + .then(|| LibraryUsageProjection { + library_id: LibraryId::parse("library-1"), + confirmed_count, + pending_count, + }) + .into_iter() + .collect(), + inventory_complete: true, + problem_count: 0, + }) + }) } } @@ -1603,27 +1987,16 @@ mod tests { { return Err(error); } - let library = find_library_mut(catalog, &request.library_id)?; match request.mutation { LibraryMemberMutation::Upsert { content, record } => { - let mut record = *record; - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Upsert( + *record, + ), + )?; self.payloads.lock().expect("payloads").insert( ( environment_key, @@ -1633,25 +2006,50 @@ mod tests { *content, ); } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } - self.payloads.lock().expect("payloads").remove(&( - environment_key, - request.library_id.as_str().to_string(), - request.skill_name, - )); + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Retire { + retirement_id, + retired_at, + }, + )?; } } Ok(()) }) } + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + let environment_key = EnvironmentKey::from_ref(&request.environment); + let mut catalogs = self.catalogs.lock().expect("catalogs"); + let catalog = catalogs.entry(environment_key.clone()).or_default(); + crate::application::library_membership::apply_membership_change( + catalog, + &request.library_id, + &request.skill_name, + crate::application::library_membership::MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + self.payloads.lock().expect("payloads").remove(&( + environment_key, + request.library_id.as_str().to_string(), + request.skill_name, + )); + Ok(()) + }) + } + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -1766,6 +2164,21 @@ mod tests { payload_manager_with_storage().0 } + fn membership_preview( + environment: &EnvironmentRef, + library_id: &LibraryId, + ) -> LibraryMembershipPreview { + LibraryMembershipPreview { + environment: environment.clone(), + library_id: library_id.clone(), + scopes: Vec::new(), + impacts: Vec::new(), + inventory_complete: true, + inventory_token: "membership-preview".to_string(), + token: "membership-preview".to_string(), + } + } + fn payload_manager_with_storage() -> ( PayloadSessionManager, Arc, @@ -1784,6 +2197,38 @@ mod tests { (manager, storage) } + #[test] + fn membership_preview_must_match_the_member_request_target() { + let environment = EnvironmentRef::Native; + let library_id = LibraryId::parse("library-1"); + let preview = membership_preview(&environment, &LibraryId::parse("library-2")); + + assert!(matches!( + validate_membership_target(&preview, &environment, &library_id), + Err(AppError::StaleContext) + )); + } + + #[test] + fn retire_preview_token_binds_the_final_membership_impact() { + let environment = EnvironmentRef::Native; + let library_id = LibraryId::parse("library-1"); + let prepared = PreparedLibraryRetire { + skill_name: "demo".to_string(), + base_token: "member-evidence".to_string(), + }; + let mut final_membership = membership_preview(&environment, &library_id); + final_membership.token = "impact-evidence".to_string(); + + let preview = prepared.bind(final_membership).unwrap(); + + assert_ne!(preview.token, "member-evidence"); + assert_eq!( + preview.token, + bound_retire_preview_token("member-evidence", &preview.membership).unwrap() + ); + } + #[tokio::test] async fn creates_lists_and_renames_a_library_without_changing_its_id() { let module = SkillLibraryModule::new(Arc::new(MemoryRepository::default())); @@ -1842,7 +2287,8 @@ mod tests { "sourceExtension": ["keep"] }, "contentManifestHash": "manifest-old" - }] + }], + "retiredSkills": [] }] }); @@ -1861,6 +2307,33 @@ mod tests { ); } + #[test] + fn catalog_rejects_active_and_retired_directory_collision() { + let active = test_library_record("CE:Review", "active"); + let retired = test_library_record("ce-review", "retired"); + let catalog = LibraryCatalog { + schema_version: LIBRARY_SCHEMA_VERSION, + libraries: vec![SkillLibraryRecord { + id: LibraryId::parse("library-1"), + name: "Library".to_string(), + skills: vec![active], + retired_skills: vec![RetiredLibrarySkillRecord { + retirement_id: RetirementId::parse("retirement-1"), + member: retired, + retired_at: "2026-09-06T00:00:00Z".to_string(), + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }], + extra: serde_json::Map::new(), + }; + + assert!(matches!( + validate_catalog(&catalog), + Err(AppError::ConfigurationCorrupted { .. }) + )); + } + #[test] fn library_update_request_contains_intent_instead_of_payload_handles() { let request = UpdateLibrarySkillsRequest { @@ -1938,6 +2411,7 @@ mod tests { test_library_record("alpha", "alpha-v1"), test_library_record("beta", "beta-v1"), ], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), @@ -2118,7 +2592,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); std::fs::create_dir_all(skills_root.join("stale")).unwrap(); @@ -2129,7 +2608,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2139,7 +2619,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Failed); assert_eq!(response.results[0].error, Some(AppError::StaleTarget)); assert_eq!(response.results[1].status, LibraryAddSkillStatus::Succeeded); - assert_eq!(response.library.skills[0].name, "ready"); + assert_eq!(response.library.as_ref().unwrap().skills[0].name, "ready"); } #[tokio::test] @@ -2182,7 +2662,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); @@ -2192,7 +2677,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2213,6 +2699,8 @@ mod tests { assert_eq!( response .library + .as_ref() + .unwrap() .skills .iter() .map(|skill| skill.name.as_str()) @@ -2263,7 +2751,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); let stale = &request.skills[0].payload; @@ -2281,7 +2774,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2291,7 +2785,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Failed); assert_eq!(response.results[0].error, Some(AppError::StalePayload)); assert_eq!(response.results[1].status, LibraryAddSkillStatus::Succeeded); - assert_eq!(response.library.skills[0].name, "ready"); + assert_eq!(response.library.as_ref().unwrap().skills[0].name, "ready"); } #[tokio::test] @@ -2339,7 +2833,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .unwrap(); @@ -2349,7 +2848,8 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) @@ -2358,7 +2858,7 @@ mod tests { assert_eq!(response.results[0].status, LibraryAddSkillStatus::Cancelled); assert_eq!(response.results[1].status, LibraryAddSkillStatus::NotRun); - assert!(response.library.skills.is_empty()); + assert!(response.library.as_ref().unwrap().skills.is_empty()); } #[tokio::test] @@ -2437,7 +2937,12 @@ mod tests { crate::environment::wsl::WslRuntime::default(), )); let preview = module - .preview_add_skills(&manager, &targets, request.clone()) + .preview_add_skills( + &manager, + &targets, + request.clone(), + membership_preview(&request.environment, &request.library_id), + ) .await .expect("preview add skill"); assert!(repository.payloads.lock().unwrap().is_empty()); @@ -2447,13 +2952,14 @@ mod tests { &targets, ExecuteAddLibrarySkillsRequest { request, - expected_token: preview.token, + expected_token: preview.token.clone(), + membership: preview.membership.clone(), acknowledge_redirect: false, }, ) .await .expect("execute add skill"); - let detail = response.library; + let detail = response.library.unwrap(); assert_eq!(response.results[0].status, LibraryAddSkillStatus::Succeeded); assert_eq!(detail.skills.len(), 1); diff --git a/src-tauri/src/application/skill_paths.rs b/src-tauri/src/application/skill_paths.rs index 3cc7512d..2a4dab57 100644 --- a/src-tauri/src/application/skill_paths.rs +++ b/src-tauri/src/application/skill_paths.rs @@ -133,7 +133,7 @@ impl SkillPathObserver { cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if requests.is_empty() { return Err(AppError::Validation { @@ -211,7 +211,7 @@ impl SkillPathObserver { cancellation: Option, ) -> Result, AppError> where - T: TargetFactResolver + ContentManifestReader, + T: TargetFactResolver + ContentManifestReader + ?Sized, { if requested_names.is_empty() { return Err(AppError::Validation { diff --git a/src-tauri/src/application/update_subjects.rs b/src-tauri/src/application/update_subjects.rs index 8bebe80d..afeca6b8 100644 --- a/src-tauri/src/application/update_subjects.rs +++ b/src-tauri/src/application/update_subjects.rs @@ -437,6 +437,7 @@ mod tests { updated_at: None, extra: serde_json::Map::new(), }], + retired_skills: Vec::new(), extra: serde_json::Map::new(), }], extra: serde_json::Map::new(), diff --git a/src-tauri/src/commands/skill_libraries.rs b/src-tauri/src/commands/skill_libraries.rs index 53c8a0bb..70264678 100644 --- a/src-tauri/src/commands/skill_libraries.rs +++ b/src-tauri/src/commands/skill_libraries.rs @@ -4,17 +4,19 @@ use crate::application::library_application::{ ApplyLibraryApplicationRequest, LibraryAgentOptions, LibraryApplicationDraft, LibraryApplicationPreview, LibraryApplicationResponse, LibraryApplicationSummary, }; +use crate::application::library_membership::LibraryMembershipOutcome; use crate::application::library_update::{ ExecuteLibraryUpdateRequest, LibraryUpdateExecutionOutcome, LibraryUpdateExecutionStage, LibraryUpdatePreview, }; use crate::application::skill_libraries::{ - ExecuteAddLibrarySkillsRequest, LibraryAddPreview, LibraryAddResponse, LibraryId, + ExecuteAddLibrarySkillsRequest, ExecuteRetireLibrarySkillRequest, LibraryAddPreview, + LibraryAddResponse, LibraryId, LibraryRetirePreview, LibraryRetireResponse, LibraryWorkspaceSnapshot, PreviewAddLibrarySkillsRequest, RemoveLibrarySkillRequest, SkillLibraryDetail, UpdateLibrarySkillsRequest, }; use crate::application::update::{UpdateCheckMode, UpdateCheckResponse}; -use crate::core::mutation::{MutationKind, MutationPhase}; +use crate::core::mutation::{CancellationSignal, MutationKind, MutationPhase}; use crate::environment::types::EnvironmentRef; use crate::environment::types::SkillLocationRef; use crate::error::AppError; @@ -88,14 +90,9 @@ pub async fn add_skills_to_library( request: ExecuteAddLibrarySkillsRequest, runtime: State<'_, RuntimeServiceGraph>, ) -> Result { - let _permit = runtime.admission().begin_exclusive_action()?; runtime - .skill_libraries() - .execute_add_skills( - runtime.payloads(), - runtime.agent_selection_targets(), - request, - ) + .library_membership() + .execute_add_skills(request, CancellationSignal::default()) .await } @@ -106,12 +103,8 @@ pub async fn preview_add_library_skills( runtime: State<'_, RuntimeServiceGraph>, ) -> Result { runtime - .skill_libraries() - .preview_add_skills( - runtime.payloads(), - runtime.agent_selection_targets(), - request, - ) + .library_membership() + .preview_add_skills(request) .await } @@ -174,17 +167,42 @@ pub async fn update_library_skills( #[tauri::command] #[specta::specta] -pub async fn remove_library_skill( +pub async fn preview_remove_library_skill( request: RemoveLibrarySkillRequest, runtime: State<'_, RuntimeServiceGraph>, -) -> Result { - let _permit = runtime.admission().begin_exclusive_action()?; +) -> Result { runtime - .skill_libraries() - .remove_skill(runtime.agent_selection_targets(), request) + .library_membership() + .preview_retire_skill(request) .await } +#[tauri::command] +#[specta::specta] +pub async fn remove_library_skill( + request: ExecuteRetireLibrarySkillRequest, + runtime: State<'_, RuntimeServiceGraph>, +) -> Result { + runtime + .library_membership() + .retire_skill(request, CancellationSignal::default()) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn resume_library_membership( + environment: EnvironmentRef, + library_id: Option, + runtime: State<'_, RuntimeServiceGraph>, +) -> Result { + let execution = runtime + .library_membership() + .resume(environment, library_id, CancellationSignal::default()) + .await?; + Ok(execution.into_parts().1) +} + #[tauri::command] #[specta::specta] pub async fn delete_skill_library( diff --git a/src-tauri/src/runtime/maintenance.rs b/src-tauri/src/runtime/maintenance.rs index f8031877..671f1046 100644 --- a/src-tauri/src/runtime/maintenance.rs +++ b/src-tauri/src/runtime/maintenance.rs @@ -5,6 +5,9 @@ use std::sync::{Arc, Mutex}; use tokio::sync::watch; +use crate::application::library_membership::{ + LibraryMembershipModule, MembershipScopeState, RetiredCleanupState, +}; use crate::application::payload_session::{ PayloadCleanupReport, PayloadSessionMaintenance, PayloadSessionManager, }; @@ -147,10 +150,12 @@ struct MaintenanceEntry { pub struct RuntimeMaintenanceCoordinator { payloads: Arc, backend: Arc, + membership: Option>, entries: Mutex>, } impl RuntimeMaintenanceCoordinator { + #[cfg(test)] pub fn new( payloads: Arc, backend: Arc, @@ -158,6 +163,20 @@ impl RuntimeMaintenanceCoordinator { Self { payloads, backend, + membership: None, + entries: Mutex::new(HashMap::new()), + } + } + + pub fn with_membership( + payloads: Arc, + backend: Arc, + membership: Arc, + ) -> Self { + Self { + payloads, + backend, + membership: Some(membership), entries: Mutex::new(HashMap::new()), } } @@ -258,6 +277,47 @@ impl RuntimeMaintenanceCoordinator { }; let outcome = self.backend.run(&environment).await; + if let Some(membership) = &self.membership { + match membership + .resume( + environment.clone(), + None, + crate::core::mutation::CancellationSignal::default(), + ) + .await + { + Ok(result) => { + if let Some(error) = result.snapshot_error { + log::warn!( + "Library membership inventory is incomplete for {environment:?}: {error}" + ); + } + for scope in result.scopes { + if !matches!(scope.state, MembershipScopeState::Synced) { + log::warn!( + "Library membership resume for {:?} remains {:?}: {:?}", + scope.context, + scope.state, + scope.error + ); + } + } + for cleanup in result.cleanup { + if cleanup.state == RetiredCleanupState::Failed { + log::warn!( + "Retired Library member cleanup failed for {}/{}: {:?}", + cleanup.library_id.as_str(), + cleanup.member_name, + cleanup.error + ); + } + } + } + Err(error) => { + log::warn!("Library membership resume failed for {environment:?}: {error}"); + } + } + } let mut issues = Vec::new(); match outcome.payload { Ok(report) => { diff --git a/src-tauri/src/runtime/mod.rs b/src-tauri/src/runtime/mod.rs index 6c968afa..65c6ff16 100644 --- a/src-tauri/src/runtime/mod.rs +++ b/src-tauri/src/runtime/mod.rs @@ -13,6 +13,7 @@ use crate::application::library_application::LibraryApplicationModule; use crate::application::library_candidates::{ LibraryCandidateSource, RepositoryLibraryCandidateSource, }; +use crate::application::library_membership::LibraryMembershipModule; use crate::application::payload_session::{PayloadSessionLimits, PayloadSessionManager}; use crate::application::runtime_admission::RuntimeAdmissionCoordinator; use crate::application::skill_libraries::{LibraryUsageProvider, SkillLibraryModule}; @@ -147,6 +148,7 @@ pub struct RuntimeServiceGraph { RuntimePlanExecutor, >, >, + library_membership: Arc, connection_probe: network_connection::RuntimeNetworkConnectionProbe, } @@ -222,10 +224,6 @@ impl RuntimeServiceGraph { wsl.clone(), admission.clone(), )); - let maintenance = Arc::new(RuntimeMaintenanceCoordinator::new( - payloads.clone(), - maintenance_backend, - )); let update_evidence = build_runtime_source_evidence_coordinator( payloads.clone(), source_snapshots.clone(), @@ -250,6 +248,20 @@ impl RuntimeServiceGraph { execution.executor(wsl.clone(), Arc::new(agent_selection_facts.clone())), Arc::new(execution.recovery_service()), )); + let library_membership = Arc::new(LibraryMembershipModule::new( + library_repository.clone(), + library_application.clone(), + library_repository.clone(), + admission.clone(), + skill_libraries.clone(), + payloads.clone(), + Arc::new(agent_selection_targets.clone()), + )); + let maintenance = Arc::new(RuntimeMaintenanceCoordinator::with_membership( + payloads.clone(), + maintenance_backend, + library_membership.clone(), + )); let library_candidates: Arc = Arc::new(RepositoryLibraryCandidateSource::new( library_repository.clone(), @@ -344,6 +356,7 @@ impl RuntimeServiceGraph { skill_libraries, library_usages, library_application, + library_membership, connection_probe, }) } @@ -462,6 +475,10 @@ impl RuntimeServiceGraph { self.library_application.as_ref() } + pub(crate) fn library_membership(&self) -> &LibraryMembershipModule { + self.library_membership.as_ref() + } + pub(crate) fn connection_probe(&self) -> &network_connection::RuntimeNetworkConnectionProbe { &self.connection_probe } diff --git a/src-tauri/src/runtime/skill_libraries.rs b/src-tauri/src/runtime/skill_libraries.rs index 39d1dae9..a98dfbea 100644 --- a/src-tauri/src/runtime/skill_libraries.rs +++ b/src-tauri/src/runtime/skill_libraries.rs @@ -14,13 +14,14 @@ use crate::application::library_application::{ LibraryApplicationRecord, LibraryApplicationResources, LibraryUsageAccumulator, VersionedApplicationRecord, }; +use crate::application::library_membership::{apply_membership_change, MembershipChange}; use crate::application::payload_session::{ PayloadLocalSource, PayloadSessionStorage, PayloadStorageKey, }; use crate::application::skill_libraries::{ validate_catalog, CommitLibraryMemberRequest, LibraryCatalog, LibraryFuture, LibraryId, LibraryMemberMutation, LibraryUsage, LibraryUsageProvider, LibraryUsageSnapshot, - LibraryUsageState, SkillLibraryRepository, + LibraryUsageState, PurgeRetiredLibraryMemberRequest, SkillLibraryRepository, }; use crate::application::skill_paths::{ResolvedSkillRoot, SkillPathObserver}; use crate::core::projects::ProjectMigrationRegistry; @@ -316,6 +317,36 @@ impl SkillLibraryRepository for RuntimeSkillLibraryRepository { }) } + fn purge_retired<'a>( + &'a self, + request: PurgeRetiredLibraryMemberRequest, + ) -> LibraryFuture<'a, Result<(), AppError>> { + Box::pin(async move { + let _io = self.io.acquire(&request.environment).await; + match &request.environment { + EnvironmentRef::Native => { + let root = self.native_root.clone(); + tokio::task::spawn_blocking(move || purge_native_retired(&root, request)) + .await + .map_err(|error| AppError::ExecutionFailed { + message: format!("retired Library member purge task failed: {error}"), + })? + } + EnvironmentRef::Wsl { distro_name } => { + let distro_name = distro_name.clone(); + let workspace = self.wsl.workspace(&distro_name)?; + self.wsl + .with_session(&distro_name, move |session| { + let workspace = workspace.clone(); + let request = request.clone(); + async move { purge_wsl_retired(&session, &workspace, request).await } + }) + .await + } + } + }) + } + fn delete_library<'a>( &'a self, environment: &'a EnvironmentRef, @@ -973,6 +1004,204 @@ fn remove_catalog_library(catalog: &mut LibraryCatalog, library_id: &str) -> Res Ok(()) } +fn retired_member<'a>( + catalog: &'a LibraryCatalog, + request: &PurgeRetiredLibraryMemberRequest, +) -> Result<&'a crate::application::skill_libraries::RetiredLibrarySkillRecord, AppError> { + let retired = catalog + .libraries + .iter() + .find(|library| library.id == request.library_id) + .and_then(|library| { + library + .retired_skills + .iter() + .find(|retired| retired.member.name == request.skill_name) + }) + .ok_or_else(|| AppError::PathNotFound { + path: request.skill_name.clone(), + })?; + if retired.retirement_id != request.retirement_id { + return Err(AppError::StaleTarget); + } + Ok(retired) +} + +fn purge_native_retired( + root: &Path, + request: PurgeRetiredLibraryMemberRequest, +) -> Result<(), AppError> { + let original_bytes = fs::read(root.join("catalog.json"))?; + let original_hash = bytes_sha256(&original_bytes); + recover_native_library_transactions(root, Some(&original_hash))?; + let mut catalog = parse_library_catalog(&original_bytes)?; + let expected_hash = retired_member(&catalog, &request)? + .member + .content_manifest_hash + .clone(); + let destination = root + .join("libraries") + .join(request.library_id.as_str()) + .join("skills") + .join(InstalledSkillResolver::install_dir_name( + &request.skill_name, + )?); + let locator = ResourceLocator { + environment: EnvironmentRef::Native, + native_path: destination.to_string_lossy().into_owned(), + }; + let target = crate::environment::planning::resolve_native_targets(&[locator])? + .pop() + .ok_or(AppError::StaleTarget)?; + match target.entry_kind { + crate::environment::planning::TargetEntryKind::Directory => { + let actual = + crate::environment::native::content_manifest::read_directory(&destination)?; + if actual.hash().as_str() != expected_hash { + return Err(AppError::StaleTarget); + } + } + crate::environment::planning::TargetEntryKind::Missing => {} + _ => return Err(AppError::StaleTarget), + } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; + let catalog_hash = bytes_sha256(&catalog_bytes); + if target.entry_kind == crate::environment::planning::TargetEntryKind::Missing { + return save_native_catalog_if_unchanged(root, Some(&original_bytes), &catalog_bytes); + } + let commit = (|| { + stage_native_skill_deletion(root, &destination)?; + prepare_native_catalog_commit(root, &catalog_hash)?; + save_native_catalog_if_unchanged(root, Some(&original_bytes), &catalog_bytes)?; + finalize_native_catalog_commit(root, &catalog_hash) + })(); + if let Err(error) = commit { + let current_hash = fs::read(root.join("catalog.json")) + .ok() + .map(|bytes| bytes_sha256(&bytes)); + recover_native_library_transactions(root, current_hash.as_deref())?; + if current_hash.as_deref() == Some(&catalog_hash) { + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +async fn purge_wsl_retired( + session: &crate::environment::wsl::WslSession, + workspace: &crate::environment::wsl::WslWorkspace, + request: PurgeRetiredLibraryMemberRequest, +) -> Result<(), AppError> { + let snapshot = workspace.read_library_catalog_once().await?; + if snapshot.generation != session.runtime_generation { + return Err(AppError::StaleEnvironment); + } + let mut catalog = snapshot + .bytes + .as_deref() + .map(parse_library_catalog) + .transpose()? + .ok_or_else(|| AppError::PathNotFound { + path: request.library_id.as_str().to_string(), + })?; + let expected_hash = retired_member(&catalog, &request)? + .member + .content_manifest_hash + .clone(); + let destination = format!( + "{}/.skill-deck/skill-libraries/libraries/{}/skills/{}", + session.home.trim_end_matches('/'), + request.library_id.as_str(), + InstalledSkillResolver::install_dir_name(&request.skill_name)?, + ); + let target = crate::environment::planning::resolve_wsl_targets( + session, + workspace, + std::slice::from_ref(&destination), + None, + ) + .await? + .pop() + .ok_or(AppError::StaleTarget)?; + let expected_content_hash = match target.entry_kind { + crate::environment::planning::TargetEntryKind::Directory => { + let manifest = crate::environment::wsl::operations::content_manifest::inspect( + workspace, + &crate::environment::content_manifest::ContentManifestTarget { + key: target.key.clone(), + location: target.destination.clone(), + }, + None, + ) + .await?; + if manifest.hash().as_str() != expected_hash { + return Err(AppError::StaleTarget); + } + Some(expected_hash) + } + crate::environment::planning::TargetEntryKind::Missing => None, + _ => return Err(AppError::StaleTarget), + }; + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Purge { + retirement_id: request.retirement_id, + }, + )?; + let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; + let action = if target.entry_kind == crate::environment::planning::TargetEntryKind::Missing { + environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + } + } else { + let (expected_anchor_device, expected_anchor_inode) = match target.key.physical_parent { + PhysicalParentIdentity::Wsl { + ref distro_name, + device, + inode, + } if distro_name.eq_ignore_ascii_case(&session.distro_name) => (device, inode), + _ => return Err(AppError::StaleTarget), + }; + environment_protocol::LibraryOperationAction::CommitMember { + library_id: request.library_id.as_str().to_string(), + skill_name: InstalledSkillResolver::install_dir_name(&request.skill_name)?, + expected_anchor_device, + expected_anchor_inode, + expected_fingerprint: target.fingerprint.0, + expected_content_hash, + mutation: environment_protocol::LibraryMemberAction::Delete, + } + }; + workspace + .execute_library_operation( + snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: snapshot.revision, + catalog_bytes, + action, + deadline_millis: 60_000, + }, + ) + .await + .map(|_| ()) +} + async fn commit_wsl_member( session: &crate::environment::wsl::WslSession, workspace: &crate::environment::wsl::WslWorkspace, @@ -1043,10 +1272,9 @@ async fn commit_wsl_member( let mut catalog = catalog_snapshot .bytes .as_deref() - .map(serde_json::from_slice) + .map(parse_library_catalog) .transpose()? .unwrap_or_default(); - validate_catalog(&catalog)?; let snapshot = crate::application::collection_records::LibraryCatalogRecordReader::new( &catalog, &request.library_id, @@ -1059,51 +1287,52 @@ async fn commit_wsl_member( return Err(AppError::StaleTarget); } let _document_changed = snapshot.document_revision != request.expected.document_revision; - let library = catalog - .libraries - .iter_mut() - .find(|library| library.id == request.library_id) - .ok_or_else(|| AppError::PathNotFound { - path: request.library_id.as_str().to_string(), - })?; match &request.mutation { LibraryMemberMutation::Upsert { record, .. } => { - let mut record = (**record).clone(); - if let Some(current) = library - .skills - .iter() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - crate::application::skill_libraries::merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - } - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Upsert((**record).clone()), + )?; } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Retire { + retirement_id: retirement_id.clone(), + retired_at: retired_at.clone(), + }, + )?; } } let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; + if matches!(request.mutation, LibraryMemberMutation::Retire { .. }) { + return workspace + .execute_library_operation( + catalog_snapshot.generation, + environment_protocol::LibraryOperationRequest { + operation_id: uuid::Uuid::new_v4().simple().to_string(), + expected_catalog_revision: catalog_snapshot.revision, + catalog_bytes, + action: environment_protocol::LibraryOperationAction::SaveCatalog { + library_ids: catalog + .libraries + .iter() + .map(|library| library.id.as_str().to_string()) + .collect(), + }, + deadline_millis: 60_000, + }, + ) + .await + .map(|_| ()); + } let payload_storage = WslPayloadSessionStorage::new(workspace.clone()); let payload_key = PayloadStorageKey::new( format!("library-{}", uuid::Uuid::new_v4().simple()), @@ -1132,7 +1361,7 @@ async fn commit_wsl_member( } } } - LibraryMemberMutation::Delete => environment_protocol::LibraryMemberAction::Delete, + LibraryMemberMutation::Retire { .. } => unreachable!("retire returns before payload I/O"), }; let result = workspace .execute_library_operation( @@ -1219,61 +1448,47 @@ fn commit_native_member(root: &Path, request: CommitLibraryMemberRequest) -> Res } let _document_changed = snapshot.document_revision != request.expected.document_revision; - let library = catalog - .libraries - .iter_mut() - .find(|library| library.id == request.library_id) - .ok_or_else(|| AppError::PathNotFound { - path: request.library_id.as_str().to_string(), - })?; match &request.mutation { LibraryMemberMutation::Upsert { record, .. } => { - let mut record = (**record).clone(); - if let Some(current) = library - .skills - .iter() - .find(|skill| skill.name == request.skill_name) - { - record.extra = current.extra.clone(); - crate::application::skill_libraries::merge_unknown_source_fields( - &mut record.source_record, - ¤t.source_record, - ); - } - if let Some(current) = library - .skills - .iter_mut() - .find(|skill| skill.name == request.skill_name) - { - *current = record; - } else { - library.skills.push(record); - library - .skills - .sort_by(|left, right| left.name.cmp(&right.name)); - } + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Upsert((**record).clone()), + )?; } - LibraryMemberMutation::Delete => { - let before = library.skills.len(); - library - .skills - .retain(|skill| skill.name != request.skill_name); - if before == library.skills.len() { - return Err(AppError::StaleTarget); - } + LibraryMemberMutation::Retire { + retirement_id, + retired_at, + } => { + apply_membership_change( + &mut catalog, + &request.library_id, + &request.skill_name, + MembershipChange::Retire { + retirement_id: retirement_id.clone(), + retired_at: retired_at.clone(), + }, + )?; } } let catalog_bytes = serde_json::to_vec_pretty(&catalog)?; let catalog_hash = bytes_sha256(&catalog_bytes); + if matches!(request.mutation, LibraryMemberMutation::Retire { .. }) { + return save_native_catalog_if_unchanged( + root, + current_catalog_bytes.as_deref(), + &catalog_bytes, + ); + } + let commit = (|| { match &request.mutation { LibraryMemberMutation::Upsert { content, .. } => { replace_native_skill(root, &destination, content)?; } - LibraryMemberMutation::Delete => { - stage_native_skill_deletion(root, &destination)?; - } + LibraryMemberMutation::Retire { .. } => unreachable!("retire returns before staging"), } prepare_native_catalog_commit(root, &catalog_hash)?; save_native_catalog_if_unchanged(root, current_catalog_bytes.as_deref(), &catalog_bytes)?; @@ -1900,6 +2115,38 @@ mod tests { assert!(fs::metadata(temp.path().join("libraries/catalog.json")).is_ok()); } + #[tokio::test] + async fn native_repository_rejects_catalog_without_retired_members() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("libraries"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("catalog.json"), + br#"{ + "schemaVersion": 3, + "libraries": [{ + "id": "backend", + "name": "Backend", + "skills": [] + }] + }"#, + ) + .unwrap(); + let repository = RuntimeSkillLibraryRepository::new( + root, + Arc::new(WslRuntime::new_with_support(false, false)), + projects(), + ); + + let error = repository.load(&EnvironmentRef::Native).await.unwrap_err(); + assert!(matches!( + error, + AppError::ConfigurationCorrupted { message } + if message.contains("Skill Library catalog") + && message.contains("retiredSkills") + )); + } + #[tokio::test] async fn native_repository_deletes_one_library_as_a_single_intent() { let temp = tempfile::tempdir().unwrap(); @@ -2278,7 +2525,7 @@ mod tests { } #[tokio::test] - async fn native_conditional_commit_upserts_and_deletes_one_complete_member() { + async fn native_conditional_commit_upserts_and_retires_one_complete_member() { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("libraries"); let repository = Arc::new(RuntimeSkillLibraryRepository::new( @@ -2298,6 +2545,11 @@ mod tests { b"---\nname: demo\ndescription: Demo\n---\nbody\n", ) .unwrap(); + let manifest_hash = crate::environment::native::content_manifest::read_directory(&source) + .unwrap() + .hash() + .as_str() + .to_string(); let payload = build_skill_payload(&source).unwrap(); repository @@ -2323,7 +2575,7 @@ mod tests { "pluginName": null, "wellKnown": null }), - content_manifest_hash: "hash".to_string(), + content_manifest_hash: manifest_hash, updated_at: None, extra: serde_json::Map::new(), }), @@ -2353,7 +2605,28 @@ mod tests { library_id: library_id.clone(), skill_name: "demo".to_string(), expected: native_member_expectation(repository.as_ref(), &library_id, "demo").await, - mutation: LibraryMemberMutation::Delete, + mutation: LibraryMemberMutation::Retire { + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), + retired_at: "2026-09-06T00:00:00Z".to_string(), + }, + }) + .await + .unwrap(); + assert!(destination.join("SKILL.md").is_file()); + let catalog = repository.load(&EnvironmentRef::Native).await.unwrap(); + assert!(catalog.libraries[0].skills.is_empty()); + assert_eq!(catalog.libraries[0].retired_skills.len(), 1); + + repository + .purge_retired(PurgeRetiredLibraryMemberRequest { + environment: EnvironmentRef::Native, + library_id: library_id.clone(), + skill_name: "demo".to_string(), + retirement_id: crate::application::skill_libraries::RetirementId::parse( + "retirement-1", + ), }) .await .unwrap(); @@ -2363,7 +2636,7 @@ mod tests { .await .unwrap() .libraries[0] - .skills + .retired_skills .is_empty()); } diff --git a/src-tauri/src/test_support/native_workflow.rs b/src-tauri/src/test_support/native_workflow.rs index 9d71223d..85d03f03 100644 --- a/src-tauri/src/test_support/native_workflow.rs +++ b/src-tauri/src/test_support/native_workflow.rs @@ -1768,6 +1768,7 @@ fn test_library_record(id: LibraryId, name: &str, version: &str) -> SkillLibrary updated_at: Some(fixed_time()), extra: serde_json::Map::new(), }], + retired_skills: Vec::new(), extra: serde_json::Map::new(), } } diff --git a/src/bindings.ts b/src/bindings.ts index 2646781b..34e093b5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -126,6 +126,14 @@ async previewAddLibrarySkills(request: PreviewAddLibrarySkillsRequest) : Promise else return { status: "error", error: e as any }; } }, +async previewRemoveLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("preview_remove_library_skill", { request }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async previewLibrarySkillUpdates(request: UpdateLibrarySkillsRequest) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("preview_library_skill_updates", { request }) }; @@ -150,7 +158,7 @@ async renameSkillLibrary(environment: EnvironmentRef, libraryId: LibraryId, name else return { status: "error", error: e as any }; } }, -async removeLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { +async removeLibrarySkill(request: ExecuteRetireLibrarySkillRequest) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("remove_library_skill", { request }) }; } catch (e) { @@ -158,6 +166,14 @@ async removeLibrarySkill(request: RemoveLibrarySkillRequest) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("resume_library_membership", { environment, libraryId }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async retryLibraryApplication(context: SkillLocationRef) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("retry_library_application", { context }) }; @@ -788,8 +804,9 @@ export type EvidenceAttempt = { checkedAtEpochMs: number; failure: EvidenceDetec export type EvidenceDetectionFailure = { reason: EvidenceFailureReason; message: string; retryAtEpochMs: number | null; providerCooldown: boolean } export type EvidenceFailureReason = "rateLimited" | "authenticationRequired" | "refNotFound" | "repositoryNotFound" | "notFoundOrUnauthorized" | "network" | "incompleteEvidence" | "sourceUnavailable" export type EvidenceFreshness = "fresh" | "cached" | "stale" | "coolingDown" | "backingOff" | "unavailable" -export type ExecuteAddLibrarySkillsRequest = { request: PreviewAddLibrarySkillsRequest; expectedToken: LibraryAddPreviewToken; acknowledgeRedirect: boolean } +export type ExecuteAddLibrarySkillsRequest = { request: PreviewAddLibrarySkillsRequest; expectedToken: LibraryAddPreviewToken; membership: LibraryMembershipPreview; acknowledgeRedirect: boolean } export type ExecuteLibraryUpdateRequest = { request: UpdateLibrarySkillsRequest; expectedToken: LibraryUpdatePreviewToken; continuation: LibraryUpdateContinuation | null; riskConfirmation: LibraryUpdateRiskConfirmation | null } +export type ExecuteRetireLibrarySkillRequest = { request: RemoveLibrarySkillRequest; expectedToken: string; membership: LibraryMembershipPreview } export type FallbackReasonCode = "symlinkUnavailable" | "crossStorageCopyRequired" | "targetCapabilityFallback" /** * 来源发现结果 @@ -916,9 +933,9 @@ export type LegacyMigrationTarget = "currentPrivate" | "standardCanonical" export type LegacyPath = { scope: LegacyPathScope; path: PathSpec; behavior: LegacyPathBehavior; migrationTarget: LegacyMigrationTarget } export type LegacyPathBehavior = "detectOnly" | "offerMigration" export type LegacyPathScope = "global" | "project" -export type LibraryAddPreview = { token: LibraryAddPreviewToken; skills: LibraryAddSkillPreview[]; redirectedDownloadHost: string | null } +export type LibraryAddPreview = { token: LibraryAddPreviewToken; skills: LibraryAddSkillPreview[]; redirectedDownloadHost: string | null; membership: LibraryMembershipPreview } export type LibraryAddPreviewToken = { generation: string; contextRevision: string; skillRevisions: LibraryAddSkillRevision[]; redirectedDownloadHost: string | null } -export type LibraryAddResponse = { results: LibraryAddSkillResult[]; library: SkillLibraryDetail } +export type LibraryAddResponse = { results: LibraryAddSkillResult[]; library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibraryAddSkillPreview = { skillName: string; targetPath: string } export type LibraryAddSkillResult = { skillName: string; status: LibraryAddSkillStatus; error: AppError | null } export type LibraryAddSkillRevision = { skillName: string; targetRevision: string; sourceRecordRevision: string } @@ -933,6 +950,10 @@ export type LibraryApplicationSummary = { orderedLibraries: SkillLibrarySummary[ export type LibraryApplicationSyncState = "synced" | "pending" | "unverified" | "recoveryRequired" export type LibraryCommitStatus = "succeeded" | "failed" | "notRun" export type LibraryId = string +export type LibraryMembershipOutcome = { scopes: MembershipScopeResult[]; cleanup: RetiredCleanupResult[]; snapshotError: AppError | null } +export type LibraryMembershipPreview = { environment: EnvironmentRef; libraryId: LibraryId; scopes: SkillLocationRef[]; impacts: MembershipScopeImpact[]; inventoryComplete: boolean; inventoryToken: string; token: string } +export type LibraryRetirePreview = { skillName: string; token: string; membership: LibraryMembershipPreview } +export type LibraryRetireResponse = { library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibrarySkillSummary = { name: string; description: string; source: string; sourceType: string; sourceUrl: string | null; skillPath: string; contentHash: string; /** * 内容所属插件。属于 Skill 自身的元数据,与 Agent 无关。 @@ -955,7 +976,7 @@ export type LibraryUpdatePreparedSource = { sourceResultId: string; source: stri export type LibraryUpdatePreparedSourceResult = { status: "acquired"; discoverySession: DiscoverySessionHandle; payloads: LibraryUpdatePreparedPayload[]; skillErrors: LibraryUpdatePreparedSkillError[]; redirectedDownloadHost: string | null } | { status: "failed"; error: ErrorReport } export type LibraryUpdatePreview = { token: LibraryUpdatePreviewToken; skillNames: string[] } export type LibraryUpdatePreviewToken = { generation: string } -export type LibraryUpdateResponse = { sources: UpdateSourceResult[]; results: LibraryUpdateSkillResult[]; outcome: UpdateOutcome; library: SkillLibraryDetail } +export type LibraryUpdateResponse = { sources: UpdateSourceResult[]; results: LibraryUpdateSkillResult[]; outcome: UpdateOutcome; library: SkillLibraryDetail | null; membership: LibraryMembershipOutcome } export type LibraryUpdateRiskConfirmation = { redirectedDownloadHosts: string[] } export type LibraryUpdateSkillResult = { skillName: string; status: LibraryUpdateSkillStatus; sourceResultId: string; contentCommit: LibraryCommitStatus; catalogCommit: LibraryCommitStatus; error: ErrorReport | null } export type LibraryUpdateSkillStatus = "succeeded" | "failed" | "nameChanged" | "deletedUpstream" | "cancelled" | "notRun" @@ -970,15 +991,15 @@ export type LibraryUsageProjection = { libraryId: LibraryId; confirmedCount: num /** * 某个 Skill 位置引用当前对象的方式。 * - * 生效与锁定是两件事:`Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有 - * 未完成的应用操作引用它、尚未确认生效。两者的并集才是成员锁定的判定依据。 + * `Confirmed` 表示配置已经起作用,`PendingAdjustment` 表示只有未完成的应用操作 + * 引用它、尚未确认生效。两者的并集用于整库删除保护和使用状态展示。 */ export type LibraryUsageState = "confirmed" | "pendingAdjustment" export type LibraryWorkspaceSnapshot = { environment: EnvironmentRef; libraries: SkillLibrarySummary[]; /** * catalog 内容的摘要。应用关系不参与该摘要,页面重新进入时自行拉取最新投影。 */ -revision: string; usageProjection: LibraryUsageProjection[] } +revision: string; usageProjection: LibraryUsageProjection[]; usageInventoryComplete: boolean; usageInventoryProblemCount: number } export type LifecycleAction = "closeCurrentWindow" | "quitApplication" | "restartApplication" export type LifecycleActionOutcome = { status: "performed" } | { status: "delegated" } | { status: "blocked"; snapshot: BackendActivitySnapshot } export type LifecycleActionRequestedEvent = { action: LifecycleAction } @@ -1007,6 +1028,11 @@ export type ManageInstallOptionState = { optionId: AgentInstallOptionId; current export type ManageSelectedEffect = "retain" | "add" | "repair" export type ManageSelectionDisabledReason = "unrecognizedEntry" export type ManageUnselectedEffect = "keepAbsent" | "remove" | "restoreLibrary" +export type MembershipScopeImpact = { context: SkillLocationRef; skills: MembershipSkillImpact[] } +export type MembershipScopeImpactKind = "added" | "switched" | "fallback" | "removed" | "unchanged" | "unverified" +export type MembershipScopeResult = { context: SkillLocationRef; state: MembershipScopeState; error: AppError | null } +export type MembershipScopeState = "synced" | "pending" | "unverified" | "recoveryRequired" | "cancelled" +export type MembershipSkillImpact = { skillName: string; kind: MembershipScopeImpactKind } export type MutationKind = "install" | "update" | "remove" | "copy" | "manageAgents" | "duplicateCleanup" | "repair" | "manageAgentDefinitions" | "projectMigration" | "addProject" | "removeProject" | "updateProjectPreference" | "updateSettings" | "manageGithubCredential" | "manageLibraries" | "resolveRecovery" export type MutationPhase = "preparing" | "acquiring" | "validating" | "committing" | "finishing" export type MutationProgress = { subject: string | null; current: number | null; total: number | null } @@ -1055,6 +1081,8 @@ export type ResolvedAgent = { definition: AgentDefinition; detection: DetectionS export type ResolvedAgentScope = { enabled: boolean; readsStandard: boolean; standardPath: string | null; privatePath: string | null; readPaths: string[]; standardPresence: DirectoryPresenceState | null; privatePresence: DirectoryPresenceState | null; legacyPaths: ResolvedPathPresence[] } export type ResolvedPathPresence = { path: string | null; presence: DirectoryPresenceState } export type ResourceLocator = { environment: EnvironmentRef; nativePath: string } +export type RetiredCleanupResult = { libraryId: LibraryId; memberName: string; retirementId: string; state: RetiredCleanupState; error: AppError | null } +export type RetiredCleanupState = "purged" | "retained" | "failed" /** * 安装范围 */ diff --git a/src/hooks/useTauriApi.ts b/src/hooks/useTauriApi.ts index 64486a98..a61be1df 100644 --- a/src/hooks/useTauriApi.ts +++ b/src/hooks/useTauriApi.ts @@ -39,7 +39,8 @@ import type { ExecuteLibraryUpdateRequest, LibraryUpdateExecutionOutcome, LibraryUpdatePreview, LibraryUpdateContinuation, LibraryUpdatePreviewToken, LibraryUpdateRiskConfirmation, UpdateLibrarySkillsRequest, - RemoveLibrarySkillRequest, + ExecuteRetireLibrarySkillRequest, LibraryMembershipOutcome, + LibraryRetirePreview, LibraryRetireResponse, RemoveLibrarySkillRequest, LibraryUsage, } from '@/bindings'; @@ -75,7 +76,8 @@ export type { ExecuteLibraryUpdateRequest, LibraryUpdateExecutionOutcome, LibraryUpdatePreview, LibraryUpdateContinuation, LibraryUpdatePreviewToken, LibraryUpdateRiskConfirmation, UpdateLibrarySkillsRequest, - RemoveLibrarySkillRequest, + ExecuteRetireLibrarySkillRequest, LibraryMembershipOutcome, + LibraryRetirePreview, LibraryRetireResponse, RemoveLibrarySkillRequest, LibraryUsage, }; @@ -311,11 +313,24 @@ export async function previewLibrarySkillUpdates( } export async function removeLibrarySkill( - request: RemoveLibrarySkillRequest, -): Promise { + request: ExecuteRetireLibrarySkillRequest, +): Promise { return unwrap(await commands.removeLibrarySkill(request)); } +export async function previewRemoveLibrarySkill( + request: RemoveLibrarySkillRequest, +): Promise { + return unwrap(await commands.previewRemoveLibrarySkill(request)); +} + +export async function resumeLibraryMembership( + environment: EnvironmentRef, + libraryId: LibraryId | null, +): Promise { + return unwrap(await commands.resumeLibraryMembership(environment, libraryId)); +} + export async function deleteSkillLibrary( environment: EnvironmentRef, libraryId: LibraryId, From 268a5a4a1f41127b46d2d6cc6cd74193eb0f5963 Mon Sep 17 00:00:00 2001 From: Hccake Date: Wed, 9 Sep 2026 22:46:37 +0800 Subject: [PATCH 11/27] feat(libraries): expose membership impact and retry workflows Present membership impact before confirmation and retain Skill, Scope and cleanup outcomes. Keep previews and retry state in LibraryWorkspace, including partial failures and stale previews. Expose reapply and unavailable saved Agent associations, preserve successful results during refresh failures, and align localized copy with the completed workflows. --- src/components/library/LibraryAddDialog.tsx | 15 ++ src/components/library/LibrarySkillCard.tsx | 12 +- .../library/LibrarySkillDetailPanel.tsx | 8 +- .../library/MembershipImpactSummary.tsx | 32 +++ .../library/MembershipOutcomeSummary.tsx | 48 ++++ .../__tests__/LibraryAddDialog.test.tsx | 78 ++++++ .../__tests__/LibrarySkillCard.test.tsx | 10 +- .../MembershipOutcomeSummary.test.tsx | 45 ++++ src/components/library/index.ts | 2 + src/components/library/useLibraryAddFlow.ts | 20 +- .../skills/LibraryApplicationStrip.tsx | 6 +- .../skills/ManageLibraryApplicationDialog.tsx | 62 ++++- src/components/skills/SkillsPanel.tsx | 18 +- src/components/skills/SkillsSection.tsx | 2 +- .../__tests__/CompactSkillList.test.tsx | 6 +- .../LibraryApplicationStrip.test.tsx | 8 +- .../ManageLibraryApplicationDialog.test.tsx | 122 +++++++-- .../skills/__tests__/SkillsSection.test.tsx | 4 +- .../library-application-copy.test.ts | 8 +- src/i18n/locales/en.json | 46 +++- src/i18n/locales/zh-CN.json | 46 +++- src/lib/libraries/__tests__/workspace.test.ts | 171 +++++++++++++ src/lib/libraries/workspace.ts | 144 ++++++++++- src/pages/LibraryPage.tsx | 241 +++++++++++++----- src/pages/__tests__/LibraryPage.test.tsx | 152 ++++++++++- src/stores/__tests__/skills-snapshots.test.ts | 2 +- src/stores/skills-data.ts | 2 +- .../__tests__/library-update.test.ts | 45 +++- 28 files changed, 1198 insertions(+), 157 deletions(-) create mode 100644 src/components/library/MembershipImpactSummary.tsx create mode 100644 src/components/library/MembershipOutcomeSummary.tsx create mode 100644 src/components/library/__tests__/MembershipOutcomeSummary.test.tsx diff --git a/src/components/library/LibraryAddDialog.tsx b/src/components/library/LibraryAddDialog.tsx index 09e8fc2a..ce8f069a 100644 --- a/src/components/library/LibraryAddDialog.tsx +++ b/src/components/library/LibraryAddDialog.tsx @@ -36,6 +36,8 @@ import { type LibraryAddPhase, type LibraryAddTarget, } from './useLibraryAddFlow'; +import { MembershipImpactSummary } from './MembershipImpactSummary'; +import { MembershipOutcomeSummary } from './MembershipOutcomeSummary'; interface LibraryAddDialogProps { open: boolean; @@ -325,6 +327,16 @@ function LibraryAddReviewStep({ flow }: { flow: LibraryAddFlow }) {

{t('libraries.addFlow.review.summary', { count: preview?.skills.length ?? 0 })}

+ {preview ? ( +
+

+ {preview.membership.inventoryComplete + ? t('libraries.membership.affectedScopes', { count: preview.membership.scopes.length }) + : t('libraries.membership.inventoryIncomplete', { count: preview.membership.scopes.length })} +

+ +
+ ) : null}
{preview?.skills.map((skill) => (
@@ -413,6 +425,9 @@ function LibraryAddResultStep({ flow }: { flow: LibraryAddFlow }) {
))}
+ {flow.membershipOutcome ? ( + + ) : null} {flow.flowError ? (

{formatAppError(flow.flowError, t)} diff --git a/src/components/library/LibrarySkillCard.tsx b/src/components/library/LibrarySkillCard.tsx index c6816501..6a1e82ba 100644 --- a/src/components/library/LibrarySkillCard.tsx +++ b/src/components/library/LibrarySkillCard.tsx @@ -29,7 +29,6 @@ interface LibrarySkillCardProps { /** 本次整库更新批次中该成员的阶段或结果。 */ updateStatus?: SkillUpdateDisplayStatus; busy?: boolean; - libraryInUse?: boolean; onClick?: (skillName: string) => void; onUpdate?: (skillName: string) => void; onRemove?: (skillName: string) => void; @@ -47,7 +46,6 @@ export const LibrarySkillCard = memo(function LibrarySkillCard({ check, updateStatus, busy = false, - libraryInUse = false, onClick, onUpdate, onRemove, @@ -196,12 +194,10 @@ export const LibrarySkillCard = memo(function LibrarySkillCard({ size="icon" className="size-7 cursor-pointer text-muted-foreground hover:bg-destructive/10 hover:text-destructive" aria-label={t('libraries.removeSkill', { name: skill.name })} - // 成员锁定是库特有的约束,用户需要读到原因; - // aria-disabled 保留指针事件,禁用状态下 Tooltip 才能触发。 - aria-disabled={busy || libraryInUse} + aria-disabled={busy} onClick={(event) => { event.stopPropagation(); - if (!busy && !libraryInUse) onRemove(skill.name); + if (!busy) onRemove(skill.name); }} >

- {libraryInUse - ? t('libraries.lockedMembership') - : t('libraries.removeSkillTitle', { name: skill.name })} + {t('libraries.removeSkillTitle', { name: skill.name })}

diff --git a/src/components/library/LibrarySkillDetailPanel.tsx b/src/components/library/LibrarySkillDetailPanel.tsx index 0821e4d2..130e3677 100644 --- a/src/components/library/LibrarySkillDetailPanel.tsx +++ b/src/components/library/LibrarySkillDetailPanel.tsx @@ -23,7 +23,6 @@ interface LibrarySkillDetailPanelProps { loading: boolean; contentError?: boolean; busy?: boolean; - libraryInUse?: boolean; onClose: () => void; onUpdate?: (skillName: string) => void; onRemove?: (skillName: string) => void; @@ -43,7 +42,6 @@ export const LibrarySkillDetailPanel = memo(function LibrarySkillDetailPanel({ loading, contentError = false, busy = false, - libraryInUse = false, onClose, onUpdate, onRemove, @@ -111,10 +109,8 @@ export const LibrarySkillDetailPanel = memo(function LibrarySkillDetailPanel({ size="icon" className="size-8 cursor-pointer text-muted-foreground hover:bg-destructive/10 hover:text-destructive" aria-label={t('libraries.removeSkill', { name: skill.name })} - title={libraryInUse - ? t('libraries.lockedMembership') - : t('libraries.removeSkillTitle', { name: skill.name })} - disabled={busy || libraryInUse} + title={t('libraries.removeSkillTitle', { name: skill.name })} + disabled={busy} onClick={() => onRemove(skill.name)} >