diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index e2ff068..c9aa4cc 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -1374,7 +1374,11 @@ impl DebugSession { } } PendingRequest::Launch { operation_id } => { - self.transition(DebugSessionState::Running); + // Adapters may emit stopped or terminated before this acknowledgement. + // A late response completes the operation, but cannot resume or resurrect it. + if self.state == DebugSessionState::Launching { + self.transition(DebugSessionState::Running); + } self.emit(DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { @@ -3161,6 +3165,62 @@ mod tests { serde_json::from_slice(&bytes[body_start..]).unwrap() } + #[test] + fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/debug/launch-event-order.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let session_id = case["id"].as_str().unwrap(); + let event = case["event"].clone(); + let expected = case["state"].clone(); + create_session(child_request(session_id, None)).unwrap(); + struct Cleanup<'a>(&'a str); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + // log-exempt: Expected absence if the test already disposed this process-free session. + let _ = destroy_session(SessionRequest { + session_id: self.0.into(), + }); + } + } + let _cleanup = Cleanup(session_id); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launching = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "native target".into(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let frame = decode_frame(&launching.outbound_frames[0]); + let stopped = receive_messages(session_id, vec![event]); + assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected); + // LLDB can report the first stop before acknowledging the launch request. + let acknowledged = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "launch", + json!({}), + )], + ); + assert_eq!( + serde_json::to_value(acknowledged.state).unwrap(), + expected, + "a response must not resume or resurrect the target" + ); + } + } + #[test] fn dap_logging_keeps_catalog_identity_and_cancellation_private() { let adapter = "lldb+private-adapter"; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 5569461..05f45d5 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -182,6 +182,8 @@ pub enum CoreCommand { ToolchainsFailureReply, /// Resolves the nearest machine-local directory pin. ToolchainsResolvePins, + /// Edits a bounded batch of machine-local directory pins without native writes. + ToolchainsEditPins, /// Resolves consumer defaults while keeping workspace inheritance symbolic. ToolchainsResolveConsumerPreferences, /// Edits one machine-local consumer preference and returns its effective input. @@ -879,6 +881,7 @@ impl CoreCommand { "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), "toolchains.resolvePins" => Some(Self::ToolchainsResolvePins), + "toolchains.editPins" => Some(Self::ToolchainsEditPins), "toolchains.resolveConsumerPreferences" => { Some(Self::ToolchainsResolveConsumerPreferences) } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index c35f814..33fb417 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -431,6 +431,9 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { CoreCommand::ToolchainsResolvePins => { plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command) } + CoreCommand::ToolchainsEditPins => { + plugin_response(id, parsed.payload, crate::toolchain::edit_pins_command) + } CoreCommand::ToolchainsResolveConsumerPreferences => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/toolchain_pins.rs b/rust/lithe-core/src/tests/toolchain_pins.rs index bfca269..3072870 100644 --- a/rust/lithe-core/src/tests/toolchain_pins.rs +++ b/rust/lithe-core/src/tests/toolchain_pins.rs @@ -259,3 +259,111 @@ fn toolchain_pins_enforce_collection_and_text_limits_before_resolution() { "plugin.toolchain.limitExceeded" ); } + +/// Calls the public JSON boundary so an unregistered command cannot pass. +fn edit(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"pin-edit","command":"toolchains.editPins","payload":payload}).to_string(), + )) + .unwrap() +} + +#[test] +fn toolchain_pin_edits_preserve_siblings_and_remove_only_the_selected_override() { + let document = local_pins(); + let changed = edit(json!({"document":document,"directory":"apps/web","edits":[ + {"tool":"java","pin":null}, + {"tool":"node","pin":pin("/opt/lithe-fixtures/node")} + ]})); + assert_eq!(changed["ok"], true, "{changed}"); + assert_eq!(changed["data"]["status"], "ok", "{changed}"); + let result = &changed["data"]["edit"]["document"]; + assert_eq!(result["toolchains"], document["toolchains"]); + assert_eq!(result["pins"]["."], document["pins"]["."]); + assert_eq!( + result["pins"][".config/tools"], + document["pins"][".config/tools"] + ); + assert!(result["pins"]["apps/web"].get("java").is_none()); + assert_eq!( + result["pins"]["apps/web"]["node"], + pin("/opt/lithe-fixtures/node") + ); + assert_eq!( + resolve(result, "apps/web/src", "java")["sourceDirectory"], + "." + ); +} + +#[test] +fn toolchain_pin_edits_reject_invalid_batches_without_a_partial_document() { + for edits in [ + json!([]), + json!([{"tool":"java","pin":pin("relative")}]), + json!([{"tool":"java","pin":null},{"tool":"java","pin":null}]), + json!([{"tool":"java"}]), + json!([{"tool":"java","pin":null,"extra":true}]), + ] { + let response = edit(json!({"document":local_pins(),"directory":".","edits":edits})); + assert_eq!(response["data"]["status"], "rejected", "{response}"); + assert!(response["data"].get("edit").is_none(), "{response}"); + } + let response = edit( + json!({"document":local_pins(),"directory":"../outside","edits":[{"tool":"java","pin":null}]}), + ); + assert_eq!(response["data"]["status"], "rejected", "{response}"); +} + +#[test] +fn toolchain_pin_edits_prune_empty_containers_and_leave_absent_removals_inert() { + let document = + json!({"version":1,"toolchains":{},"pins":{".":{"java":pin("/opt/lithe-fixtures/java")}}}); + let response = + edit(json!({"document":document,"directory":".","edits":[{"tool":"java","pin":null}]})); + assert_eq!( + response["data"]["edit"]["document"], + json!({"version":1,"toolchains":{}}) + ); + let document = json!({"version":1,"toolchains":{}}); + let response = + edit(json!({"document":document,"directory":"src","edits":[{"tool":"node","pin":null}]})); + assert_eq!(response["data"]["edit"]["document"], document); +} + +#[test] +fn toolchain_pin_edits_match_the_shared_boundary_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchain-pin-edit.json" + )) + .unwrap(); + let response = edit(fixture["request"].clone()); + assert_eq!(response["data"]["edit"]["document"], fixture["document"]); +} + +#[test] +fn toolchain_pin_edits_enforce_final_collection_limits_and_validate_the_source() { + let mut request = + json!({"document":local_pins(),"directory":".","edits":[{"tool":"java","pin":null}]}); + request["document"]["unknown"] = json!(true); + assert_eq!(edit(request)["data"]["status"], "rejected"); + let edits: Vec<_> = (0..65) + .map(|index| json!({"tool":format!("tool-{index}"),"pin":null})) + .collect(); + let response = edit(json!({"document":local_pins(),"directory":".","edits":edits})); + assert_eq!( + response["data"]["error"]["code"], + "plugin.toolchain.limitExceeded" + ); + let tools: serde_json::Map = (0..64) + .map(|index| (format!("tool-{index}"), pin("/fixture/tool"))) + .collect(); + let document = json!({"version":1,"toolchains":{},"pins":{".":tools}}); + let response = edit( + json!({"document":document,"directory":".","edits":[{"tool":"extra","pin":pin("/fixture/extra")}]}), + ); + assert_eq!( + response["data"]["error"]["code"], + "plugin.toolchain.limitExceeded" + ); + assert!(response["data"].get("edit").is_none()); +} diff --git a/rust/lithe-core/src/toolchain/mod.rs b/rust/lithe-core/src/toolchain/mod.rs index 3a9b8ee..0e54407 100644 --- a/rust/lithe-core/src/toolchain/mod.rs +++ b/rust/lithe-core/src/toolchain/mod.rs @@ -54,6 +54,11 @@ pub fn resolve_pins_command(input: Value) -> Result { command(input, pins::resolve, "resolution") } +/// Applies one atomic batch of inert directory-pin edits without native writes. +pub fn edit_pins_command(input: Value) -> Result { + command(input, pins::edit, "edit") +} + /// Resolves inert consumer choices without materializing symbolic inheritance. pub fn resolve_consumer_preferences_command(input: Value) -> Result { command(input, preferences::resolve, "preferences") diff --git a/rust/lithe-core/src/toolchain/pins.rs b/rust/lithe-core/src/toolchain/pins.rs index 1737798..e7c7d8b 100644 --- a/rust/lithe-core/src/toolchain/pins.rs +++ b/rust/lithe-core/src/toolchain/pins.rs @@ -45,6 +45,85 @@ pub(crate) fn resolve(input: &Value) -> Validation { } } +/// Edits a bounded batch against one captured document without performing I/O. +/// A null pin removes only that override; invalid batches return no document. +pub(crate) fn edit(input: &Value) -> Validation { + let request = object(input, "input", &["document", "directory", "edits"])?; + let document = required(request, "document", "input")?; + validate(document)?; + let directory = text( + required(request, "directory", "input")?, + "directory", + MAX_DIRECTORY_BYTES, + )?; + if !valid_directory(directory) { + return Err(invalid( + "directory", + "Expected a normalized workspace-relative directory.", + )); + } + let edits = required(request, "edits", "input")? + .as_array() + .ok_or_else(|| invalid("edits", "Expected an array."))?; + count(edits.len(), MAX_TOOLS_PER_DIRECTORY, "edits")?; + if edits.is_empty() { + return Err(invalid("edits", "Expected at least one edit.")); + } + let mut seen = std::collections::BTreeSet::new(); + let mut updated = document.clone(); + for (index, edit) in edits.iter().enumerate() { + let field = format!("edits.{index}"); + let edit = object(edit, &field, &["tool", "pin"])?; + let tool = text( + required(edit, "tool", &field)?, + &format!("{field}.tool"), + 192, + )?; + if !valid_tool_id(tool) || !seen.insert(tool) { + return Err(invalid( + format!("{field}.tool"), + "Expected a unique valid tool identifier.", + )); + } + let pin = required(edit, "pin", &field)?; + if pin.is_null() { + if let Some(pins) = updated.get_mut("pins").and_then(Value::as_object_mut) { + if let Some(tools) = pins.get_mut(directory).and_then(Value::as_object_mut) { + tools.remove(tool); + if tools.is_empty() { + pins.remove(directory); + } + } + if pins.is_empty() { + updated + .as_object_mut() + .expect("validated document") + .remove("pins"); + } + } + } else { + validate_pin(pin, &format!("{field}.pin"))?; + let pins = updated + .as_object_mut() + .expect("validated document") + .entry("pins") + .or_insert_with(|| json!({})); + let tools = pins + .as_object_mut() + .expect("validated pins") + .entry(directory.to_string()) + .or_insert_with(|| json!({})); + tools + .as_object_mut() + .expect("validated tools") + .insert(tool.to_string(), pin.clone()); + } + } + // Enforce document budgets after the entire batch, allowing replacement at capacity. + validate(&updated)?; + Ok(json!({"document":updated})) +} + /// Checks the complete local document, retaining legacy entries and dormant tool IDs. /// This validation is lexical only; paths are never probed, canonicalized, or launched. pub(crate) fn validate(document: &Value) -> Validation<()> { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 463b5e1..2fc45a1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1585,6 +1585,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | | `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | | `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | +| `toolchains.editPins` | Apply one bounded batch of directory pin edits to a captured local document without writing native files | | `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority | | `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes | | `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures | @@ -7015,3 +7016,30 @@ rename) before deleting anything. The result is `{delete, keep, seed}`, the firs seven days old or of unknown age), `abandoned` and `expired` (deleted). The cache and the ledger live in the platform cache directory, never in the app bundle or install directory. The fixture is `shared/fixtures/agent/attachment-retention-v1.json`. + +### Atomic local toolchain pin editing + +`toolchains.editPins` accepts `{document,directory,edits:[{tool,pin}]}` and returns +`{status:"ok",edit:{document}}`. It uses the v1 local toolchain document and the +same normalized directory, tool ID, pin and size limits as `resolvePins`. +A batch contains 1–64 unique tool IDs. `pin:null` removes that directory's explicit +override (ancestor inheritance remains effective); a pin object replaces it. +Only emptied containers on the edited path are pruned. Legacy `toolchains`, +dormant pins and unrelated directories remain intact. Unknown fields, duplicate +IDs, malformed pins and over-budget documents reject the whole batch with no +partial document. Rejections use the existing `plugin.toolchain.invalidParams` +and `plugin.toolchain.limitExceeded` structured error envelope. + +The command performs no I/O and grants no execution authority. The host owns +explicit Save confirmation, byte-baseline conflict detection, cancellation, +ignore-file preparation, atomic native replacement, and re-reading the source. +See `shared/fixtures/toolchain-pin-edit.json` for the cross-client vector. + +### Debug launch response ordering + +A debug adapter may emit `stopped` or `terminated` before replying to `launch` +or `attach`. The acknowledgement completes that operation; it changes state to +`running` only if the session is still `launching`. It never resumes a paused +target or resurrects a terminated one. See +`shared/fixtures/debug/launch-event-order.json`. Hosts serialize each Core update, +its outbound frames and its UI publication in the order returned by the engine. diff --git a/shared/fixtures/debug/launch-event-order.json b/shared/fixtures/debug/launch-event-order.json new file mode 100644 index 0000000..52200d2 --- /dev/null +++ b/shared/fixtures/debug/launch-event-order.json @@ -0,0 +1,25 @@ +{ + "cases": [ + { + "id": "late-launch-paused", + "event": { + "type": "event", + "event": "stopped", + "body": { + "reason": "breakpoint", + "threadId": 1 + } + }, + "state": "paused" + }, + { + "id": "late-launch-ended", + "event": { + "type": "event", + "event": "terminated", + "body": {} + }, + "state": "terminated" + } + ] +} diff --git a/shared/fixtures/toolchain-pin-edit.json b/shared/fixtures/toolchain-pin-edit.json new file mode 100644 index 0000000..598e42e --- /dev/null +++ b/shared/fixtures/toolchain-pin-edit.json @@ -0,0 +1,56 @@ +{ + "request": { + "document": { + "version": 1, + "toolchains": { + "legacy": { + "home": "legacy home" + } + }, + "pins": { + ".": { + "java": { + "home": "/fixture/java", + "version": "21", + "provider": "host:manual" + } + } + } + }, + "directory": "apps/web", + "edits": [ + { + "tool": "node", + "pin": { + "home": "/fixture/node", + "version": "22", + "provider": "host:manual" + } + } + ] + }, + "document": { + "version": 1, + "toolchains": { + "legacy": { + "home": "legacy home" + } + }, + "pins": { + ".": { + "java": { + "home": "/fixture/java", + "version": "21", + "provider": "host:manual" + } + }, + "apps/web": { + "node": { + "home": "/fixture/node", + "version": "22", + "provider": "host:manual" + } + } + } + } +}