From 3453b55764630cfbe9a7b6fec35dba1c26af0c73 Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Tue, 7 Jul 2026 17:35:35 +0200 Subject: [PATCH 1/4] fix: service account + scm owner + docker required error + iris_doc delete error handling --- README.md | 156 ++++++++++++++++++ crates/iris-agentic-dev-core/src/tools/scm.rs | 44 ++--- 2 files changed, 170 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a207d166..48937222 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,162 @@ or invoke the Linux binary via `wsl.exe`: --- +## Connecting to IRIS + +### Native IRIS on Windows or Linux (no Docker) + +Add a `.iris-agentic-dev.toml` file to your project root: + +```toml +host = "localhost" +web_port = 80 # IIS default for IRIS 2024.1+; use 52773 for pre-2024.1 +namespace = "USER" +username = "_SYSTEM" +password = "SYS" +``` + +#### Port reference + +| IRIS version | Web server | Default port | +|---|---|---| +| 2024.1+ on Windows | IIS | 80 | +| 2024.1+ on Linux | Apache | 80 | +| Pre-2024.1 (any OS) | Private Web Server (PWS) | 52773 | + +#### Windows IIS: `/api` web application required + +This is the most common failure on Windows. IIS needs an explicit `/api` web application mapped to the IRIS Web Gateway module. Without it, `/api/atelier` returns 404 — even when the Management Portal loads correctly. + +**To fix:** + +1. Open **IIS Manager** → expand your server → **Sites** → **Default Web Site** +2. Right-click → **Add Application**. Set alias: `api`, physical path: `C:\InterSystems\IRIS\CSP\bin` (adjust to your install path) +3. Add a wildcard script handler mapping: executable = `CSPms.dll`, no verb restriction +4. Verify `CSP.ini` contains an `[APP_PATH:/api]` section + +See the [`iris-windows-iis-setup` skill](./light-skills/skills/iris-windows-iis-setup/SKILL.md) for full step-by-step instructions with verification commands. + +**`localhost` vs `127.0.0.1`**: On some older Web Gateway builds, using `localhost` causes a brief connection error before each request. If you see connection delays, change the config to `host = "127.0.0.1"`. + +### Docker (community image) + +Run `iris-agentic-dev init` in your project directory — it detects any running IRIS containers and writes `.iris-agentic-dev.toml` automatically: + +```bash +iris-agentic-dev init +``` + +Or configure manually: + +```toml +container = "myapp-iris" +namespace = "MYAPP" +``` + +### Docker (enterprise image) + +Enterprise IRIS images (`intersystems/iris`, `intersystems/irishealth`) ship without a built-in web server. Run the ISC Web Gateway container alongside IRIS: + +```yaml +services: + iris: + image: containers.intersystems.com/intersystems/iris:2026.1 + ports: ["4972:1972"] + webgateway: + image: containers.intersystems.com/intersystems/webgateway:2026.1 + ports: ["52773:80"] + entrypoint: ["/bin/sh", "/init.sh"] + volumes: ["./webgateway-init.sh:/init.sh:ro"] +``` + +See the [`iris-vscode-objectscript` skill](./light-skills/skills/iris-vscode-objectscript/SKILL.md) for a working `webgateway-init.sh`. + +### VS Code Server Manager (zero-config) + +If the [InterSystems Server Manager](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) extension is installed, iris-agentic-dev reads your server list from VS Code's `settings.json` and resolves credentials from the OS keychain automatically — no `.iris-agentic-dev.toml` needed. + +**Single server configured:** auto-connects, no extra setup. + +**Multiple servers configured:** set `IRIS_SERVER_NAME` to the map key from `intersystems.servers`: + +```bash +export IRIS_SERVER_NAME=dev-local +``` + +Credentials are stored under keychain service `"intersystems-server-credentials"` — the auth provider ID used by Server Manager in all VS Code-compatible forks (Cursor, Windsurf, VS Code Insiders). If a credential is missing, iris-agentic-dev fails fast with a message directing you to reconnect in VS Code (right-click the server → **Reconnect**) rather than silently falling through to other discovery sources. + +Use `check_config` to see which servers were detected and whether credentials resolved: + +```json +{ + "server_manager": { + "available": true, + "servers": [ + { "name": "dev-local", "active": true, "credential_status": "resolved" } + ] + } +} +``` + +### Per-connection policy (fleet / operate mode) + +Add `[policy.]` blocks to `.iris-agentic-dev.toml` to restrict which tool categories are permitted on a given Server Manager server: + +```toml +[policy.prod] +allow = ["query", "search", "docs"] +``` + +Blocked calls return `error_code: "POLICY_GATE"` with the list of allowed categories. Omit the block entirely to permit everything. Available categories: `compile`, `execute`, `query`, `search`, `docs`, `source_control`, `debug`, `admin`, `skill`, `kb`. + +For multi-instance fleet workflows (`mode = "operate"`), see the [fleet roles spec](./specs/003-workspace-config/) for the full `[instance.*]` config format and role-gate behavior. + +### Connection discovery order + +iris-agentic-dev resolves the IRIS connection in this order — first match wins: + +1. CLI flags (`--host`, `--web-port`, `--scheme`) +2. `.iris-agentic-dev.toml` in the workspace root +3. Environment variables (`IRIS_HOST`, etc.) +4. VS Code `settings.json` (`objectscript.conn` / `intersystems.servers`) +5. VS Code Server Manager keychain (`intersystems.servers` + OS keychain credential) +6. Running Docker containers (scored by workspace name similarity) +7. Localhost port scan (52773, 41773, 51773, 8080) + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `IRIS_HOST` | `localhost` | IRIS web gateway hostname | +| `IRIS_WEB_PORT` | `52773` | Web gateway port | +| `IRIS_SCHEME` | `http` | `http` or `https` | +| `IRIS_WEB_PREFIX` | *(empty)* | URL path prefix for non-root gateway installs | +| `IRIS_USERNAME` | `_SYSTEM` | IRIS username | +| `IRIS_PASSWORD` | `SYS` | IRIS password | +| `IRIS_SERVICE_USERNAME` | *(empty)* | Restricted service account for arbitrary-execution tools (see below) | +| `IRIS_SERVICE_PASSWORD` | *(empty)* | Password for `IRIS_SERVICE_USERNAME` | +| `IRIS_NAMESPACE` | `USER` | Default namespace | +| `IRIS_CONTAINER` | *(empty)* | Docker container name — required for Docker-dependent tools | +| `IRIS_SERVER_NAME` | *(empty)* | Server Manager server name when multiple are configured | +| `OBJECTSCRIPT_WORKSPACE` | `$PWD` | Workspace root for `.iris-agentic-dev.toml` lookup | + +### Privilege separation for arbitrary execution + +`iris_execute`, `iris_execute_method`, `iris_query` (`mode="write"`), and `iris_global` +(`set`/`kill`) can run arbitrary ObjectScript/SQL. Under a `%All` account these can edit class +and routine code — even by indirection (`$classmethod`, `$method("%Sa"_"ve")`, `xecute`) — +bypassing the SCM lock and the `CODE_EDIT_BLOCKED` string filter, since no static text filter +can be exhaustive against a fully-privileged identity. + +Set `IRIS_SERVICE_USERNAME` / `IRIS_SERVICE_PASSWORD` to a **least-privilege** IRIS account +(no `%Development` resource, code database mounted read-only). Those four tools then authenticate +as that account, so code edits fail with `` at the IRIS privilege layer regardless of +indirection. Code-writing tools (`iris_document` put, `iris_source_control`, `iris_compile`) +deliberately keep using the primary `IRIS_USERNAME`, so SCM checkouts and audit stay attributed +to the real user. When unset, all tools use the primary connection (unchanged behaviour). + +--- + ## Skills — improve AI output for ObjectScript Skills are concise instruction files that teach your AI assistant ObjectScript patterns and diff --git a/crates/iris-agentic-dev-core/src/tools/scm.rs b/crates/iris-agentic-dev-core/src/tools/scm.rs index 4c50370e..327e5908 100644 --- a/crates/iris-agentic-dev-core/src/tools/scm.rs +++ b/crates/iris-agentic-dev-core/src/tools/scm.rs @@ -482,16 +482,10 @@ fn derive_scm_status( let owner_opt = Some(owner.trim().to_string()).filter(|s| !s.is_empty()); let any_signal = is_in_sc || has_checkout || has_undo_checkout || has_add_to_sc || owner_opt.is_some(); - // No signal at all → no SCM configured, document is freely editable. - // (GetStatus errors with empty menus also land here — treat as uncontrolled.) + // No signal at all → we genuinely don't know. Reporting "editable: true" here is the exact + // false-positive this rewrite eliminates (GetStatus errors and empty menus both land here). if !any_signal { - return Some(ScmStatus { - controlled: false, - editable: true, - locked: false, - checked_out_by_me: false, - owner: None, - }); + return None; } // A document is uncontrolled iff we are offered the action to add it to source control. @@ -558,19 +552,13 @@ fn parse_scm_status_line(line: &str) -> Option<(bool, bool, bool, bool, String)> let has_undo_checkout = flag(parts.next())?; let has_add_to_sc = flag(parts.next())?; let owner = parts.next().unwrap_or("").trim().to_string(); - Some(( - is_in_sc, - has_checkout, - has_undo_checkout, - has_add_to_sc, - owner, - )) + Some((is_in_sc, has_checkout, has_undo_checkout, has_add_to_sc, owner)) } /// Fallback owner detection from the SCM provider's native `checked out by user ''` notice. /// /// Some source-control providers emit a native `NOTICE: … is currently checked out by user -/// 'todor', and was last updated at 2026-07-07 12:34:56` message (often followed by a ``) +/// 'xxx', and was last updated at 2026-07-07 12:34:56` message (often followed by a ``) /// that short-circuits `status_check_code` before the `SCMSTATUS|` sentinel is ever written. In /// that case `parse_scm_status_line` finds nothing, yet the raw output already tells us the /// document is controlled and locked by another user — so we scrape it here instead of reporting @@ -583,9 +571,11 @@ fn parse_checked_out_by(raw: &str) -> Option<(String, Option)> { use std::sync::OnceLock; static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| { - // "checked out by user 'todor'" — timestamp captured only if the line isn't truncated. - regex::Regex::new(r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?") - .expect("static SCM checked-out regex is valid") + // "checked out by user 'xxx'" — timestamp captured only if the line isn't truncated. + regex::Regex::new( + r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?", + ) + .expect("static SCM checked-out regex is valid") }); let caps = re.captures(raw)?; let owner = caps.get(1)?.as_str().trim().to_string(); @@ -1038,16 +1028,10 @@ mod tests { } #[test] - fn test_derive_no_signal_is_uncontrolled() { - // No in-SC flag, no menu items, no owner → no SCM configured in this namespace. - // Must return controlled:false, editable:true — NOT None/SCM_UNAVAILABLE, which - // was a false-positive error for namespaces that simply have no SCM. - let s = derive_scm_status(false, false, false, false, "", "me").unwrap(); - assert!(!s.controlled); - assert!(s.editable); - assert!(!s.locked); - assert!(!s.checked_out_by_me); - assert!(s.owner.is_none()); + fn test_derive_no_signal_is_indeterminate() { + // No in-SC flag, no menu items, no owner → indeterminate → None (caller reports + // SCM_UNAVAILABLE), never a false "editable: true". + assert!(derive_scm_status(false, false, false, false, "", "me").is_none()); } #[test] From ca4e9f55e00313fab243b5019e899367cc146cab Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Thu, 9 Jul 2026 14:32:53 +0200 Subject: [PATCH 2/4] fix: elicitation on source control --- crates/iris-agentic-dev-core/src/iris/connection.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/iris-agentic-dev-core/src/iris/connection.rs b/crates/iris-agentic-dev-core/src/iris/connection.rs index 47b95d50..9a658860 100644 --- a/crates/iris-agentic-dev-core/src/iris/connection.rs +++ b/crates/iris-agentic-dev-core/src/iris/connection.rs @@ -450,10 +450,6 @@ impl IrisConnection { // A residual like is often left as a benign side effect of an // SCM provider's internal Read even when the operation fully succeeded; // appending it to a non-empty result corrupted otherwise-valid output. - // Only surface a non-exception $ZERROR when the body produced NO output. - // A residual like is often left as a benign side effect of an - // SCM provider's internal Read even when the operation fully succeeded; - // appending it to a non-empty result corrupted otherwise-valid output. " If (out=\"\") && (ze'=\"\") && (ze'=\",\") { Set out = \"ERROR($ZERROR): \"_ze_$Char(10) }" .into(), " Quit out".into(), From d8d69465d31f84e6afeaa4f5dfe3310f7b63eef2 Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Wed, 15 Jul 2026 13:28:37 +0200 Subject: [PATCH 3/4] fix: elicitation on each edit was too much, fixed by checking the source control status before + adding cache on checked out items --- .../iris-agentic-dev-core/src/elicitation.rs | 117 ++++++++++++++++++ crates/iris-agentic-dev-core/src/tools/doc.rs | 72 ++++++++++- crates/iris-agentic-dev-core/src/tools/mod.rs | 25 +++- crates/iris-agentic-dev-core/src/tools/scm.rs | 31 +++-- .../tests/integration/test_doc_live.rs | 59 +++++---- .../integration/test_iris_doc_depth_live.rs | 17 ++- 6 files changed, 275 insertions(+), 46 deletions(-) diff --git a/crates/iris-agentic-dev-core/src/elicitation.rs b/crates/iris-agentic-dev-core/src/elicitation.rs index b44636ec..607ae7d8 100644 --- a/crates/iris-agentic-dev-core/src/elicitation.rs +++ b/crates/iris-agentic-dev-core/src/elicitation.rs @@ -42,6 +42,68 @@ pub struct PendingElicitation { pub expires_at: Instant, } +/// TTL for a cached "this document is checked out by me" entry. Kept short so a +/// stale entry (e.g. someone reverted the checkout out-of-band) self-heals quickly; +/// the IRIS-side write rejection is the ultimate backstop, and any SCM action we run +/// on the doc invalidates the entry immediately (see [`CheckoutCache::invalidate`]). +const CHECKOUT_CACHE_TTL: Duration = Duration::from_secs(60); + +/// Session-scoped cache of documents we have already checked out under this connection. +/// +/// The pre-write SCM probe (query MenuItems / UserAction) is one IRIS round-trip per write. +/// On a chained surgical edit (repeated insert/delete_lines on the same doc) that probe +/// returns the same "already checked out by me → proceed" answer every time, so we cache it. +/// +/// Keyed by `(namespace, document)`. Entries expire after [`CHECKOUT_CACHE_TTL`] and are +/// cleared explicitly whenever an SCM action (undo checkout, check-in, disconnect) runs on +/// the doc. A cached write that IRIS still rejects must clear its own entry so the retry +/// re-probes rather than looping on a bad cache. +#[derive(Clone, Default)] +pub struct CheckoutCache(Arc>>); + +impl CheckoutCache { + pub fn new() -> Self { + Self::default() + } + + fn key(namespace: &str, document: &str) -> (String, String) { + (namespace.to_string(), document.to_string()) + } + + /// Record that `document` in `namespace` is checked out by us (or freely writable), + /// so subsequent writes can skip the pre-write SCM probe until the entry expires. + pub fn mark(&self, namespace: &str, document: &str) { + self.0.lock().unwrap().insert( + Self::key(namespace, document), + Instant::now() + CHECKOUT_CACHE_TTL, + ); + } + + /// Returns true if we have a live (non-expired) checkout entry for this document. + /// Expired entries are removed on access so a cache miss always re-probes IRIS. + pub fn is_checked_out(&self, namespace: &str, document: &str) -> bool { + let mut store = self.0.lock().unwrap(); + let key = Self::key(namespace, document); + match store.get(&key) { + Some(expires) if Instant::now() <= *expires => true, + Some(_) => { + store.remove(&key); + false + } + None => false, + } + } + + /// Drop the cached checkout entry for this document — call after any SCM action that + /// changes checkout state (undo/checkin/disconnect) or after a write IRIS rejected. + pub fn invalidate(&self, namespace: &str, document: &str) { + self.0 + .lock() + .unwrap() + .remove(&Self::key(namespace, document)); + } +} + #[derive(Clone, Default)] pub struct ElicitationStore(Arc>>); @@ -352,4 +414,59 @@ mod tests { assert!(store.lookup(&expired_id).is_none()); assert!(store.lookup(&fresh_id).is_some()); } + + // ── CheckoutCache ───────────────────────────────────────────────────────── + #[test] + fn test_checkout_cache_miss_by_default() { + let cache = CheckoutCache::new(); + assert!(!cache.is_checked_out("USER", "Foo.cls")); + } + + #[test] + fn test_checkout_cache_hit_after_mark() { + let cache = CheckoutCache::new(); + cache.mark("USER", "Foo.cls"); + assert!(cache.is_checked_out("USER", "Foo.cls")); + } + + #[test] + fn test_checkout_cache_is_keyed_by_namespace_and_doc() { + let cache = CheckoutCache::new(); + cache.mark("USER", "Foo.cls"); + // Same doc, different namespace → miss. + assert!(!cache.is_checked_out("DVP", "Foo.cls")); + // Same namespace, different doc → miss. + assert!(!cache.is_checked_out("USER", "Bar.cls")); + } + + #[test] + fn test_checkout_cache_invalidate_clears_entry() { + let cache = CheckoutCache::new(); + cache.mark("USER", "Foo.cls"); + cache.invalidate("USER", "Foo.cls"); + assert!(!cache.is_checked_out("USER", "Foo.cls")); + } + + #[test] + fn test_checkout_cache_invalidate_missing_is_noop() { + let cache = CheckoutCache::new(); + cache.invalidate("USER", "Nonexistent.cls"); // must not panic + assert!(!cache.is_checked_out("USER", "Nonexistent.cls")); + } + + #[test] + fn test_checkout_cache_expired_entry_is_a_miss() { + let cache = CheckoutCache::new(); + // Insert an already-expired entry directly (expires 1s in the past). + cache.0.lock().unwrap().insert( + ("USER".to_string(), "Old.cls".to_string()), + Instant::now() - Duration::from_secs(1), + ); + assert!( + !cache.is_checked_out("USER", "Old.cls"), + "expired entry must read as a miss" + ); + // And the expired entry is evicted on access. + assert!(cache.0.lock().unwrap().is_empty()); + } } diff --git a/crates/iris-agentic-dev-core/src/tools/doc.rs b/crates/iris-agentic-dev-core/src/tools/doc.rs index c2b9845e..263abb25 100644 --- a/crates/iris-agentic-dev-core/src/tools/doc.rs +++ b/crates/iris-agentic-dev-core/src/tools/doc.rs @@ -204,6 +204,7 @@ pub async fn handle_iris_doc( client: &reqwest::Client, p: IrisDocParams, elicitation_store: &crate::elicitation::ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { // Elicitation resume — user answered a prior SCM checkout dialog. Handled here, // before mode dispatch, so it works for EVERY write path (put and the surgical @@ -244,6 +245,9 @@ pub async fn handle_iris_doc( return err_json("SCM_CHECKOUT_FAILED", &out); } } + // Checkout is now committed server-side — cache it so the chained edits that + // typically follow (insert/delete_lines) skip the redundant pre-write probe. + checkout_cache.mark(&pending.namespace, &pending.document); let resume_content = pending.content.as_deref().unwrap_or(""); let result = do_write( @@ -290,14 +294,16 @@ pub async fn handle_iris_doc( }; match mode { DocMode::Get => handle_get(iris, client, p).await, - DocMode::Put => handle_put(iris, client, p, elicitation_store).await, + DocMode::Put => handle_put(iris, client, p, elicitation_store, checkout_cache).await, DocMode::Delete => handle_delete(iris, client, p).await, DocMode::Head => handle_head(iris, client, p).await, DocMode::Fragment => handle_fragment(iris, client, p).await, DocMode::Compiled => handle_compiled(iris, client, p).await, DocMode::List => handle_list(iris, client, p).await, - DocMode::Insert => handle_insert(iris, client, p, elicitation_store).await, - DocMode::DeleteLines => handle_delete_lines(iris, client, p, elicitation_store).await, + DocMode::Insert => handle_insert(iris, client, p, elicitation_store, checkout_cache).await, + DocMode::DeleteLines => { + handle_delete_lines(iris, client, p, elicitation_store, checkout_cache).await + } } } @@ -397,6 +403,7 @@ async fn handle_put( client: &reqwest::Client, p: IrisDocParams, elicitation_store: &crate::elicitation::ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { let name = p.name.as_deref().unwrap_or(""); let ns = &p.namespace; @@ -432,6 +439,7 @@ async fn handle_put( ns, p.compile, elicitation_store, + checkout_cache, ) .await } @@ -439,6 +447,9 @@ async fn handle_put( /// Run the SCM pre-write check, then write. Shared by mode=put and the surgical /// edit modes (insert/delete_lines) so they all honour source-control checkout and /// the elicitation dialog identically. `content` is the full document body to write. +// Args are all distinct scalars/handles threaded straight through from the tool entry point; +// bundling them into a struct would add indirection without clarifying anything. +#[allow(clippy::too_many_arguments)] async fn write_with_scm( iris: &IrisConnection, client: &reqwest::Client, @@ -447,18 +458,46 @@ async fn write_with_scm( ns: &str, compile: bool, elicitation_store: &crate::elicitation::ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { + // Fast path: if we already checked this doc out earlier this session (cache hit), skip the + // pre-write SCM probe entirely — it is one IRIS round-trip that returns the same "proceed" + // answer every time on a chained surgical edit. A stale entry self-heals: the write below + // still goes through IRIS, and if it is rejected we invalidate so the retry re-probes. + if checkout_cache.is_checked_out(ns, name) { + let result = do_write(iris, client, name, content, ns, compile).await?; + if !write_result_succeeded(&result) { + // Cache was stale (checkout lost out-of-band) — drop it so the next call re-probes. + checkout_cache.invalidate(ns, name); + } + return Ok(result); + } + // SCM pre-write check — uses SourceControlCreate for a proper session (HTTP-compatible). // %GetImplementationObject does not exist on any IRIS version; use Interface API instead. + // + // First inspect the MenuItems: if %UndoCheckout is offered, WE already hold the checkout, + // so we must NOT re-run the %CheckOut probe. Re-invoking %CheckOut on a doc we already hold + // returns action=1 ("needs confirmation dialog"), which made every chained surgical edit + // (insert/delete_lines on an already-checked-out doc) re-elicit "requires checkout" forever. + // In that case emit a PROCEED sentinel and write directly. let n = name.replace('"', "\"\""); // ObjectScript double-quote escaping let scm_check = format!( - "set scmClass=##class(%Studio.SourceControl.Interface).SourceControlClassGet() if scmClass=\"\" {{ write \"NO_SCM\" }} else {{ set sc=##class(%Studio.SourceControl.Interface).SourceControlCreate(\"{u}\",\"{p}\",.c,.f,.o) set obj=$get(%SourceControl) if '$IsObject(obj) {{ write \"NO_SCM\" }} else {{ set action=0 set msg=\"\" set target=\"\" set reload=0 set sc=obj.UserAction(0,\"%SourceMenu,%CheckOut\",\"{n}\",\"\",.action,.target,.msg,.reload) write action_\"|\"_msg }} }}", + "set scmClass=##class(%Studio.SourceControl.Interface).SourceControlClassGet() if scmClass=\"\" {{ write \"NO_SCM\" }} else {{ set sc=##class(%Studio.SourceControl.Interface).SourceControlCreate(\"{u}\",\"{p}\",.c,.f,.o) set obj=$get(%SourceControl) if '$IsObject(obj) {{ write \"NO_SCM\" }} else {{ set hasUndoCheckout=0 try {{ set rset=##class(%ResultSet).%New(\"%Studio.SourceControl.Interface:MenuItems\") set sc=rset.Execute(\"%SourceMenu\",\"{n}\",\"\") while rset.Next() {{ if rset.GetData(2)&&(rset.GetData(1)=\"%UndoCheckout\") {{ set hasUndoCheckout=1 }} }} }} catch {{}} if hasUndoCheckout {{ write \"PROCEED|\" }} else {{ set action=0 set msg=\"\" set target=\"\" set reload=0 set sc=obj.UserAction(0,\"%SourceMenu,%CheckOut\",\"{n}\",\"\",.action,.target,.msg,.reload) write action_\"|\"_msg }} }} }}", u = iris.username.replace('"', "\"\""), p = iris.password.replace('"', "\"\""), ); + // Whether the probe told us the doc is already writable by us (PROCEED / already checked out). + // Only such a "we hold it" outcome is safe to cache — NOT NO_SCM (no source control at all), + // where there is no checkout to remember. + let mut we_hold_checkout = false; if let Ok(out) = iris.execute_via_generator(&scm_check, ns, client).await { let out = out.trim().to_string(); - if out != "NO_SCM" && !out.is_empty() { + // "NO_SCM"/empty → no source control; "PROCEED" → we already hold the checkout. + // Both skip the checkout dialog and fall through to do_write below. + if out.starts_with("PROCEED") { + we_hold_checkout = true; + } else if out != "NO_SCM" && !out.is_empty() { let parts: Vec<&str> = out.splitn(2, '|').collect(); let action_code = parts .first() @@ -488,7 +527,24 @@ async fn write_with_scm( } } - do_write(iris, client, name, content, ns, compile).await + let result = do_write(iris, client, name, content, ns, compile).await?; + // Remember the checkout only when the probe confirmed we hold it AND the write landed, so + // the next chained edit skips the probe. Never cache when there is no SCM (nothing to hold). + if we_hold_checkout && write_result_succeeded(&result) { + checkout_cache.mark(ns, name); + } + Ok(result) +} + +/// Inspect a `do_write` result and report whether the write succeeded (JSON `success:true`). +fn write_result_succeeded(result: &rmcp::model::CallToolResult) -> bool { + result + .content + .first() + .and_then(|c| c.raw.as_text()) + .and_then(|t| serde_json::from_str::(&t.text).ok()) + .map(|v| v["success"] == serde_json::Value::Bool(true)) + .unwrap_or(false) } async fn do_write( @@ -891,6 +947,7 @@ async fn handle_insert( client: &reqwest::Client, p: IrisDocParams, elicitation_store: &crate::elicitation::ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { let name = p.name.as_deref().unwrap_or(""); if name.is_empty() { @@ -948,6 +1005,7 @@ async fn handle_insert( &p.namespace, p.compile, elicitation_store, + checkout_cache, ) .await?; Ok(finalize_edit( @@ -970,6 +1028,7 @@ async fn handle_delete_lines( client: &reqwest::Client, p: IrisDocParams, elicitation_store: &crate::elicitation::ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { let name = p.name.as_deref().unwrap_or(""); if name.is_empty() { @@ -1047,6 +1106,7 @@ async fn handle_delete_lines( &p.namespace, p.compile, elicitation_store, + checkout_cache, ) .await?; Ok(finalize_edit( diff --git a/crates/iris-agentic-dev-core/src/tools/mod.rs b/crates/iris-agentic-dev-core/src/tools/mod.rs index f4fe3693..a9766132 100644 --- a/crates/iris-agentic-dev-core/src/tools/mod.rs +++ b/crates/iris-agentic-dev-core/src/tools/mod.rs @@ -1896,6 +1896,9 @@ pub struct IrisTools { pub history: Arc>>, /// Pending elicitation state for SCM dialogs. pub elicitation_store: Arc, + /// Session-scoped cache of documents already checked out by us, so chained writes + /// (insert/delete_lines/put) skip the redundant pre-write SCM checkout probe. + pub checkout_cache: Arc, /// UUID-keyed in-memory log store for progressive disclosure (027). pub log_store: Arc>, /// Session-scoped TTL cache for %Dictionary introspection results (037). @@ -1934,6 +1937,7 @@ impl IrisTools { exec_client, history: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(50))), elicitation_store: Arc::new(ElicitationStore::new()), + checkout_cache: Arc::new(crate::elicitation::CheckoutCache::new()), log_store: Arc::new(std::sync::Mutex::new(log_store::LogStore::new( log_max, log_ttl, ))), @@ -2194,6 +2198,7 @@ impl IrisTools { exec_client, history: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(50))), elicitation_store: Arc::new(ElicitationStore::new()), + checkout_cache: Arc::new(crate::elicitation::CheckoutCache::new()), log_store: Arc::new(std::sync::Mutex::new(log_store::LogStore::new( log_max, log_ttl, ))), @@ -3458,7 +3463,14 @@ do ##class(%UnitTest.Manager).RunTest("{pattern}","{flags}","{token}")"#, let iris = self.get_iris_reloaded().await?; tracing::info!(namespace = %p.namespace, "iris_doc"); let client = self.http_client(); - let result = doc::handle_iris_doc(&iris, client, p, &self.elicitation_store).await; + let result = doc::handle_iris_doc( + &iris, + client, + p, + &self.elicitation_store, + &self.checkout_cache, + ) + .await; self.record_call("iris_doc", result.is_ok()); result } @@ -5104,9 +5116,14 @@ Methods: } } } - let result = - scm::handle_iris_source_control(&iris, self.http_client(), p, &self.elicitation_store) - .await; + let result = scm::handle_iris_source_control( + &iris, + self.http_client(), + p, + &self.elicitation_store, + &self.checkout_cache, + ) + .await; self.record_call("iris_source_control", result.is_ok()); result } diff --git a/crates/iris-agentic-dev-core/src/tools/scm.rs b/crates/iris-agentic-dev-core/src/tools/scm.rs index 327e5908..75207e13 100644 --- a/crates/iris-agentic-dev-core/src/tools/scm.rs +++ b/crates/iris-agentic-dev-core/src/tools/scm.rs @@ -101,6 +101,7 @@ pub async fn handle_iris_source_control( client: &reqwest::Client, p: ScmParams, elicitation_store: &ElicitationStore, + checkout_cache: &crate::elicitation::CheckoutCache, ) -> Result { let raw_doc = p.document.as_deref().unwrap_or(""); let doc_owned; @@ -155,6 +156,9 @@ pub async fn handle_iris_source_control( }; let out = out.lines().next().unwrap_or("").trim().to_string(); if out.is_empty() { + // Any resumed SCM action (checkout/undo/checkin/disconnect) changes checkout state, + // so drop the cached entry — the next write re-probes and re-caches if still ours. + checkout_cache.invalidate(&pending.namespace, &pending.document); return ok_json( serde_json::json!({"success": true, "document": pending.document, "action_id": action_id}), ); @@ -301,6 +305,8 @@ pub async fn handle_iris_source_control( })) } } + // Checkout committed — cache it so a following iris_doc write skips the probe. + checkout_cache.mark(ns, doc); return ok_json( serde_json::json!({"success": true, "document": doc, "editable": true}), ); @@ -353,9 +359,14 @@ pub async fn handle_iris_source_control( let (action_code, msg) = parse_action_msg(out); match action_code { - 0 => ok_json( - serde_json::json!({"success": true, "document": doc, "action_id": action_id}), - ), + 0 => { + // A completed execute (undo checkout / checkin / disconnect / …) changes + // checkout state — drop any cached entry so the next write re-probes. + checkout_cache.invalidate(ns, doc); + ok_json( + serde_json::json!({"success": true, "document": doc, "action_id": action_id}), + ) + } 1 => { // Yes/No confirmation let eid = elicitation_store.insert( @@ -552,7 +563,13 @@ fn parse_scm_status_line(line: &str) -> Option<(bool, bool, bool, bool, String)> let has_undo_checkout = flag(parts.next())?; let has_add_to_sc = flag(parts.next())?; let owner = parts.next().unwrap_or("").trim().to_string(); - Some((is_in_sc, has_checkout, has_undo_checkout, has_add_to_sc, owner)) + Some(( + is_in_sc, + has_checkout, + has_undo_checkout, + has_add_to_sc, + owner, + )) } /// Fallback owner detection from the SCM provider's native `checked out by user ''` notice. @@ -572,10 +589,8 @@ fn parse_checked_out_by(raw: &str) -> Option<(String, Option)> { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| { // "checked out by user 'xxx'" — timestamp captured only if the line isn't truncated. - regex::Regex::new( - r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?", - ) - .expect("static SCM checked-out regex is valid") + regex::Regex::new(r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?") + .expect("static SCM checked-out regex is valid") }); let caps = re.captures(raw)?; let owner = caps.get(1)?.as_str().trim().to_string(); diff --git a/crates/iris-agentic-dev-core/tests/integration/test_doc_live.rs b/crates/iris-agentic-dev-core/tests/integration/test_doc_live.rs index b4d41718..b543d179 100644 --- a/crates/iris-agentic-dev-core/tests/integration/test_doc_live.rs +++ b/crates/iris-agentic-dev-core/tests/integration/test_doc_live.rs @@ -3,7 +3,7 @@ //! IRIS_HOST=localhost IRIS_WEB_PORT=52780 \ //! cargo test --test test_doc_live -- --ignored --nocapture --test-threads=1 -use iris_agentic_dev_core::elicitation::ElicitationStore; +use iris_agentic_dev_core::elicitation::{CheckoutCache, ElicitationStore}; use iris_agentic_dev_core::iris::connection::{DiscoverySource, IrisConnection}; use iris_agentic_dev_core::tools::doc::{handle_iris_doc, IrisDocParams}; @@ -45,6 +45,7 @@ async fn test_doc_put_and_get_cls() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let name = "CoverageTest.DocLive.cls"; let content = "Class CoverageTest.DocLive {}"; @@ -57,7 +58,7 @@ async fn test_doc_put_and_get_cls() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "put failed: {}", json); @@ -68,7 +69,7 @@ async fn test_doc_put_and_get_cls() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "get failed: {}", json); assert!(json["content"].as_str().unwrap().contains("DocLive")); @@ -80,7 +81,7 @@ async fn test_doc_put_and_get_cls() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, p, &store).await; + let _ = handle_iris_doc(&iris, &client, p, &store, &cache).await; } // ──────────────────────────────────────────────────────────────────────────── @@ -94,6 +95,7 @@ async fn test_doc_put_mac_injects_routine_header() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let name = "CoverageTest.DocLiveMac.mac"; let content = "Write \"Hello\"\nQuit"; @@ -106,7 +108,7 @@ async fn test_doc_put_mac_injects_routine_header() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "put .mac failed: {}", json); @@ -117,7 +119,7 @@ async fn test_doc_put_mac_injects_routine_header() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, p, &store).await; + let _ = handle_iris_doc(&iris, &client, p, &store, &cache).await; } // ──────────────────────────────────────────────────────────────────────────── @@ -131,6 +133,7 @@ async fn test_doc_put_inc_injects_routine_header() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let name = "CoverageTest.DocLiveInc.inc"; let content = "#define MyMacro 123"; @@ -143,7 +146,7 @@ async fn test_doc_put_inc_injects_routine_header() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "put .inc failed: {}", json); @@ -154,7 +157,7 @@ async fn test_doc_put_inc_injects_routine_header() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, p, &store).await; + let _ = handle_iris_doc(&iris, &client, p, &store, &cache).await; } // ──────────────────────────────────────────────────────────────────────────── @@ -168,6 +171,7 @@ async fn test_doc_head_exists() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); // Put a class first so we have something known to HEAD against let put_p: IrisDocParams = serde_json::from_value(serde_json::json!({ @@ -177,7 +181,7 @@ async fn test_doc_head_exists() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, put_p, &store).await; + let _ = handle_iris_doc(&iris, &client, put_p, &store, &cache).await; let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "head", @@ -185,7 +189,7 @@ async fn test_doc_head_exists() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "head failed: {}", json); assert_eq!(json["exists"], true, "exists should be true"); @@ -197,7 +201,7 @@ async fn test_doc_head_exists() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, del_p, &store).await; + let _ = handle_iris_doc(&iris, &client, del_p, &store, &cache).await; } // ──────────────────────────────────────────────────────────────────────────── @@ -211,6 +215,7 @@ async fn test_doc_head_not_found() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "head", @@ -218,7 +223,7 @@ async fn test_doc_head_not_found() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); // head returns success:true always, but exists:false for 404 assert_eq!(json["success"], true); @@ -236,6 +241,7 @@ async fn test_doc_get_not_found() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "get", @@ -243,7 +249,7 @@ async fn test_doc_get_not_found() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], false); assert_eq!(json["error_code"], "NOT_FOUND", "error_code: {}", json); @@ -260,6 +266,7 @@ async fn test_doc_batch_get() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "get", @@ -267,7 +274,7 @@ async fn test_doc_batch_get() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "batch get failed: {}", json); let docs = json["documents"].as_array().unwrap(); @@ -285,6 +292,7 @@ async fn test_doc_insert_and_delete_lines() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let name = "CoverageTest.DocLiveInsert.cls"; @@ -297,7 +305,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "put failed: {}", json); @@ -308,7 +316,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let fetch_result = handle_iris_doc(&iris, &client, fetch_p, &store).await; + let fetch_result = handle_iris_doc(&iris, &client, fetch_p, &store, &cache).await; let fetch_json = result_json(fetch_result); // IRIS normalizes "Class Foo {\n\n}" → ["Class Foo", "{", "", "}"], so line 2 = "{" // Use the actual content at line index 1 (0-based) as the stale-edit guard. @@ -330,7 +338,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "insert failed: {}", json); assert_eq!(json["edit"], "insert"); @@ -342,7 +350,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true); assert!( @@ -369,7 +377,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "delete_lines failed: {}", json); assert_eq!(json["edit"], "delete_lines"); @@ -381,7 +389,7 @@ async fn test_doc_insert_and_delete_lines() { "namespace": "USER" })) .unwrap(); - let _ = handle_iris_doc(&iris, &client, p, &store).await; + let _ = handle_iris_doc(&iris, &client, p, &store, &cache).await; } // ──────────────────────────────────────────────────────────────────────────── @@ -395,6 +403,7 @@ async fn test_doc_put_missing_name_returns_error() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "put", @@ -403,7 +412,7 @@ async fn test_doc_put_missing_name_returns_error() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], false); assert_eq!(json["error_code"], "MISSING_PARAMS", "error: {}", json); @@ -420,6 +429,7 @@ async fn test_doc_delete() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let name = "CoverageTest.DocLiveDelete.cls"; @@ -431,7 +441,7 @@ async fn test_doc_delete() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "put failed: {}", json); @@ -442,7 +452,7 @@ async fn test_doc_delete() { "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], true, "delete failed: {}", json); } @@ -458,13 +468,14 @@ async fn test_doc_get_missing_name_returns_error() { return; }; let store = ElicitationStore::new(); + let cache = CheckoutCache::new(); let p: IrisDocParams = serde_json::from_value(serde_json::json!({ "mode": "get", "namespace": "USER" })) .unwrap(); - let result = handle_iris_doc(&iris, &client, p, &store).await; + let result = handle_iris_doc(&iris, &client, p, &store, &cache).await; let json = result_json(result); assert_eq!(json["success"], false); assert_eq!(json["error_code"], "MISSING_PARAMS", "error: {}", json); diff --git a/crates/iris-agentic-dev-core/tests/integration/test_iris_doc_depth_live.rs b/crates/iris-agentic-dev-core/tests/integration/test_iris_doc_depth_live.rs index b06fb945..341408ad 100644 --- a/crates/iris-agentic-dev-core/tests/integration/test_iris_doc_depth_live.rs +++ b/crates/iris-agentic-dev-core/tests/integration/test_iris_doc_depth_live.rs @@ -4,7 +4,7 @@ //! IRIS_HOST=localhost IRIS_WEB_PORT=52780 \ //! cargo test --test test_iris_doc_depth_live -- --ignored --nocapture -use iris_agentic_dev_core::elicitation::ElicitationStore; +use iris_agentic_dev_core::elicitation::{CheckoutCache, ElicitationStore}; use iris_agentic_dev_core::iris::connection::{DiscoverySource, IrisConnection}; use iris_agentic_dev_core::tools::doc::{ handle_iris_doc, handle_iris_execute_method, IrisDocParams, @@ -78,8 +78,11 @@ async fn test_fragment_live_library_integer() { return; }; let store = ElicitationStore::default(); + let cache = CheckoutCache::default(); let p = fragment_params("%Library.Integer.cls", 1, 5); - let result = handle_iris_doc(&iris, &client, p, &store).await.unwrap(); + let result = handle_iris_doc(&iris, &client, p, &store, &cache) + .await + .unwrap(); let text = result.content[0].raw.as_text().unwrap().text.clone(); let v: serde_json::Value = serde_json::from_str(&text).unwrap(); println!("fragment result: {v}"); @@ -101,8 +104,11 @@ async fn test_compiled_live_library_integer() { return; }; let store = ElicitationStore::default(); + let cache = CheckoutCache::default(); let p = compiled_params("%Library.Integer.cls"); - let result = handle_iris_doc(&iris, &client, p, &store).await.unwrap(); + let result = handle_iris_doc(&iris, &client, p, &store, &cache) + .await + .unwrap(); let text = result.content[0].raw.as_text().unwrap().text.clone(); let v: serde_json::Value = serde_json::from_str(&text).unwrap(); println!( @@ -137,8 +143,11 @@ async fn test_list_live_library_cls() { return; }; let store = ElicitationStore::default(); + let cache = CheckoutCache::default(); let p = list_params("%Library.*", "CLS", 5); - let result = handle_iris_doc(&iris, &client, p, &store).await.unwrap(); + let result = handle_iris_doc(&iris, &client, p, &store, &cache) + .await + .unwrap(); let text = result.content[0].raw.as_text().unwrap().text.clone(); let v: serde_json::Value = serde_json::from_str(&text).unwrap(); println!("list result: {v}"); From 17fa2761fa958ebc34e81f78ab2727235cc71e81 Mon Sep 17 00:00:00 2001 From: Dorian TETU Date: Wed, 15 Jul 2026 14:12:24 +0200 Subject: [PATCH 4/4] fix: test lint and cache construction in test --- .../tests/integration/test_handlers_live.rs | 34 +++++++++++++++++-- .../tests/symbols_local_tests.rs | 3 ++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/iris-agentic-dev-core/tests/integration/test_handlers_live.rs b/crates/iris-agentic-dev-core/tests/integration/test_handlers_live.rs index 10622fc1..3cf0115f 100644 --- a/crates/iris-agentic-dev-core/tests/integration/test_handlers_live.rs +++ b/crates/iris-agentic-dev-core/tests/integration/test_handlers_live.rs @@ -407,7 +407,14 @@ fn test_handle_iris_doc_get_object_cls() { expected: None, line: None, }; - let r = handle_iris_doc(&conn, &client, p, &elicitation_store).await; + let r = handle_iris_doc( + &conn, + &client, + p, + &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), + ) + .await; let v = result_json(r); assert!( v.get("success").is_some(), @@ -454,7 +461,14 @@ fn test_handle_iris_doc_head_object_cls() { line: None, }; // Must not panic; any structured JSON response is acceptable - let r = handle_iris_doc(&conn, &client, p, &elicitation_store).await; + let r = handle_iris_doc( + &conn, + &client, + p, + &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), + ) + .await; let v = result_json(r); assert!( v.get("success").is_some(), @@ -699,7 +713,14 @@ fn test_handle_iris_doc_batch_get() { expected: None, line: None, }; - let r = handle_iris_doc(&conn, &client, p, &elicitation_store).await; + let r = handle_iris_doc( + &conn, + &client, + p, + &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), + ) + .await; let v = result_json(r); assert!( v.get("success").is_some(), @@ -10185,6 +10206,9 @@ Method GetName() As %String { QUIT ..Name }\n\ // Covers symbols_local.rs lines 606-613: Err(_) from std::fs::read for a .cls path. // Using a zero-permission .cls file — fs::read returns EACCES. +// Unix-only: relies on chmod 0o000 to force an fs::read EACCES, which has no +// Windows equivalent (a zero-mode file is still readable by the owner there). +#[cfg(unix)] #[tokio::test] async fn test_dispatch_iris_symbols_local_cls_read_error() { let tools = match make_iris_tools() { @@ -11682,6 +11706,7 @@ async fn test_doc_put_returns_200_with_status_errors() { line: None, }, &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), ) .await; @@ -11760,6 +11785,7 @@ async fn test_doc_put_compile_non_2xx_compile_request() { line: None, }, &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), ) .await; @@ -11827,6 +11853,7 @@ async fn test_doc_delete_non_2xx_non_404() { line: None, }, &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), ) .await; @@ -11892,6 +11919,7 @@ async fn test_doc_put_non_2xx_upload() { line: None, }, &elicitation_store, + &iris_agentic_dev_core::elicitation::CheckoutCache::new(), ) .await; diff --git a/crates/iris-agentic-dev-core/tests/symbols_local_tests.rs b/crates/iris-agentic-dev-core/tests/symbols_local_tests.rs index 61494905..5cdfc90d 100644 --- a/crates/iris-agentic-dev-core/tests/symbols_local_tests.rs +++ b/crates/iris-agentic-dev-core/tests/symbols_local_tests.rs @@ -527,7 +527,10 @@ fn scan_dir_skips_symlinks() { // Try to create a symlink (may fail on some systems) let symlink_path = dir.path().join("Link"); let target = dir.path().join("Real.cls"); + #[cfg(unix)] let _ = std::os::unix::fs::symlink(&target, &symlink_path); + #[cfg(windows)] + let _ = std::os::windows::fs::symlink_file(&target, &symlink_path); let result = scan_workspace(dir.path(), "*", 100); // Should find the real file and skip the symlink without errors