Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<server-name>]` 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 `<PROTECT>` 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
Expand Down
117 changes: 117 additions & 0 deletions crates/iris-agentic-dev-core/src/elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<HashMap<(String, String), Instant>>>);

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<Mutex<HashMap<String, PendingElicitation>>>);

Expand Down Expand Up @@ -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());
}
}
4 changes: 0 additions & 4 deletions crates/iris-agentic-dev-core/src/iris/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,6 @@ impl IrisConnection {
// A residual like <ENDOFFILE> 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 <ENDOFFILE> 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(),
Expand Down
Loading
Loading