From 7d396099de9f3cc6d20c7f19433dcb80930cace5 Mon Sep 17 00:00:00 2001 From: ashish16052 Date: Sat, 25 Jul 2026 10:30:00 +0530 Subject: [PATCH] refactor: remove config and materials from runtime key Identity is now (workspace, user_id, project_dir) only. Config, materials, and env are applied at server start and require a restart to take effect. --- docs/opencode-config.md | 4 +- docs/users-and-workspaces.md | 4 +- src/opencode_runtime/cli.py | 2 - src/opencode_runtime/runtime.py | 6 +-- src/opencode_runtime/server.py | 22 ++------- tests/test_cli.py | 4 +- tests/test_multi_tenant.py | 17 +++---- tests/test_server.py | 80 +++++++++++++++------------------ tests/test_session.py | 8 ++-- 9 files changed, 58 insertions(+), 89 deletions(-) diff --git a/docs/opencode-config.md b/docs/opencode-config.md index 53aaf4e..a98fe67 100644 --- a/docs/opencode-config.md +++ b/docs/opencode-config.md @@ -61,7 +61,7 @@ async with OpenCodeRuntime(project_dir="/path/to/repo") as r: ## runtime_dir -When set, each server gets its own isolated `HOME` and config file (`opencode.json`) under `runtime_dir/servers//`. Without it, OpenCode uses your real user environment and any `config` or `materials` passed to the runtime are ignored. +When set, each server gets its own isolated environment under `runtime_dir`. Without it, OpenCode uses your real user environment and any `config` or `materials` passed to the runtime are ignored. ```python async with OpenCodeRuntime(runtime_dir=".opencode-runtime") as r: @@ -91,4 +91,4 @@ async with OpenCodeRuntime( print(response.text) ``` -Note: `config` and `materials` affect server identity — different values produce a separate server process. `env` does not; if the server is already running, a different `env` passed to `session()` has no effect. +`config`, `materials`, and `env` are applied when the server starts. Changes take effect only after the server is restarted. diff --git a/docs/users-and-workspaces.md b/docs/users-and-workspaces.md index c88eaf5..07044bc 100644 --- a/docs/users-and-workspaces.md +++ b/docs/users-and-workspaces.md @@ -72,7 +72,7 @@ async with OpenCodeRuntime(runtime_dir=".opencode-runtime") as r: ) ``` -Different `config` or `materials` produce different server keys — so `acme/alice` and `hobby/bob` run on entirely separate server processes even if `project_dir` is the same. +`acme/alice` and `hobby/bob` have different workspaces, so they always get separate servers with separate history. ## Point each tenant at their own repo @@ -120,6 +120,6 @@ Each server runs as a separate OS process with its own isolated directory under └── tmp/ ``` -No server can read another's home, config, or history. The directory name is a hash of `(workspace, user_id, project_dir, materials, config)` — same inputs always produce the same directory, so servers survive restarts and resume where they left off. +No server can read another's home, config, or history. Servers survive restarts and resume where they left off. Isolation is at the process and filesystem level. If your use case allows users to run arbitrary shell commands via the `bash` tool, run each server in its own container for a stronger boundary. diff --git a/src/opencode_runtime/cli.py b/src/opencode_runtime/cli.py index 55cc1e9..1cfd020 100644 --- a/src/opencode_runtime/cli.py +++ b/src/opencode_runtime/cli.py @@ -100,8 +100,6 @@ async def _serve(args: argparse.Namespace) -> None: workspace=args.workspace, user_id=args.user_id, project_dir=project_dir, - materials=materials, - config={}, ) manager = ServerManager() diff --git a/src/opencode_runtime/runtime.py b/src/opencode_runtime/runtime.py index 1e42b37..8c1caee 100644 --- a/src/opencode_runtime/runtime.py +++ b/src/opencode_runtime/runtime.py @@ -95,9 +95,7 @@ async def session( ) -> OpenCodeSession: """Create a session backed by this runtime. - Each unique combination of workspace, user_id, materials, and config - maps to a dedicated OpenCode instance process. The instance is started - on first use and reused for subsequent sessions with the same key. + The instance is started on first use and reused for subsequent calls. Args: workspace: Logical tenant/workspace name, e.g. ``"acme"``. @@ -119,8 +117,6 @@ async def session( workspace, user_id, self.project_dir, - effective_materials, - effective_config, ) server_dir = self.runtime_dir / "servers" / key if self.runtime_dir is not None else None diff --git a/src/opencode_runtime/server.py b/src/opencode_runtime/server.py index 082ab00..8826389 100644 --- a/src/opencode_runtime/server.py +++ b/src/opencode_runtime/server.py @@ -150,27 +150,13 @@ def _compute_runtime_key( workspace: str | None, user_id: str | None, project_dir: Path, - materials: Materials, - config: dict[str, Any], ) -> str: - """Compute a stable 16-char key for a unique server configuration. - - Same inputs always produce the same key. Different inputs (different - workspace, user, materials, or config) produce different keys and - therefore get separate server processes. - """ + """Compute a stable key from (workspace, user_id, project_dir).""" payload = "|".join( [ workspace or "", user_id or "", str(project_dir), - repr( - sorted( - str(m) - for m in (materials if isinstance(materials, list) else [materials or ""]) - ) - ), - json.dumps(config, sort_keys=True, default=str), ] ) return hashlib.sha256(payload.encode()).hexdigest()[:16] @@ -179,9 +165,9 @@ def _compute_runtime_key( class ServerManager: """Manages a pool of OpenCode instance processes. - Each unique combination of workspace, user_id, project_dir, materials, - and config gets its own isolated OpenCode instance. Instances are started - on demand and reused when the same key is requested again. + Each unique combination of workspace, user_id, and project_dir gets its + own isolated OpenCode instance. Instances are started on demand and + reused when the same key is requested again. The registry is the single source of truth for server state — there is no in-memory cache of it, so every call consults the registry and diff --git a/tests/test_cli.py b/tests/test_cli.py index ee900fb..2c5c8c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -281,9 +281,7 @@ def test_serve_stale_entry_cleaned_and_restarted(tmp_path): from opencode_runtime.server import _compute_runtime_key # Compute the same key serve will use - key = _compute_runtime_key( - workspace=None, user_id=None, project_dir=Path(tmp_path), materials=None, config={} - ) + key = _compute_runtime_key(workspace=None, user_id=None, project_dir=Path(tmp_path)) registry.write(make_entry(key=key, pid=99999999, project_dir=str(tmp_path))) args = ns( diff --git a/tests/test_multi_tenant.py b/tests/test_multi_tenant.py index 409ecde..bddfd45 100644 --- a/tests/test_multi_tenant.py +++ b/tests/test_multi_tenant.py @@ -1,9 +1,10 @@ """ Multi-tenant tests against the real opencode binary. -Verifies that different workspace/user/config/materials combinations -produce isolated server processes, and that same combinations reuse -the same server. +Verifies that different workspace/user combinations produce isolated server +processes, and that same combinations reuse the same server. Config and +materials are not part of server identity — different values for the same +(workspace, user_id, project_dir) reuse the same server. No model or agent calls — no API keys required. """ @@ -51,13 +52,13 @@ async def test_same_workspace_and_user_reuses_server(self, runtime): s2 = await runtime.session(workspace="acme", user_id="u_1") assert s1.raw_client.base_url == s2.raw_client.base_url - async def test_different_config_gets_different_server(self, runtime): - """Same workspace, different config → different server.""" + async def test_different_config_reuses_server(self, runtime): + """Same workspace, different config → same server (config is not identity).""" s1 = await runtime.session(workspace="acme", config={"model": "anthropic/claude-haiku-4-5"}) s2 = await runtime.session( workspace="acme", config={"model": "anthropic/claude-sonnet-4-5"} ) - assert s1.raw_client.base_url != s2.raw_client.base_url + assert s1.raw_client.base_url == s2.raw_client.base_url class TestServerDirs: @@ -92,8 +93,8 @@ async def test_stop_single_tenant(self, tmp_path, monkeypatch): await r.session(workspace="acme") await r.session(workspace="beta") - acme_key = _compute_runtime_key("acme", None, Path(tmp_path), None, {}) - beta_key = _compute_runtime_key("beta", None, Path(tmp_path), None, {}) + acme_key = _compute_runtime_key("acme", None, Path(tmp_path)) + beta_key = _compute_runtime_key("beta", None, Path(tmp_path)) acme_entry = registry.read(acme_key) beta_entry = registry.read(beta_key) diff --git a/tests/test_server.py b/tests/test_server.py index d1f2c22..ab8c509 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -20,42 +20,32 @@ class TestComputeRuntimeKey: def test_same_inputs_produce_same_key(self, tmp_path): - k1 = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) - k2 = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) + k1 = _compute_runtime_key("acme", "u_1", tmp_path) + k2 = _compute_runtime_key("acme", "u_1", tmp_path) assert k1 == k2 def test_different_workspace_different_key(self, tmp_path): - k1 = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) - k2 = _compute_runtime_key("beta", "u_1", tmp_path, None, {}) + k1 = _compute_runtime_key("acme", "u_1", tmp_path) + k2 = _compute_runtime_key("beta", "u_1", tmp_path) assert k1 != k2 def test_different_user_different_key(self, tmp_path): - k1 = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) - k2 = _compute_runtime_key("acme", "u_2", tmp_path, None, {}) - assert k1 != k2 - - def test_different_config_different_key(self, tmp_path): - k1 = _compute_runtime_key(None, None, tmp_path, None, {"model": "a"}) - k2 = _compute_runtime_key(None, None, tmp_path, None, {"model": "b"}) - assert k1 != k2 - - def test_different_materials_different_key(self, tmp_path): - k1 = _compute_runtime_key(None, None, tmp_path, "./mat/a", {}) - k2 = _compute_runtime_key(None, None, tmp_path, "./mat/b", {}) + k1 = _compute_runtime_key("acme", "u_1", tmp_path) + k2 = _compute_runtime_key("acme", "u_2", tmp_path) assert k1 != k2 def test_none_workspace_and_user_stable(self, tmp_path): """No workspace/user → still produces a stable key (default server).""" - k1 = _compute_runtime_key(None, None, tmp_path, None, {}) - k2 = _compute_runtime_key(None, None, tmp_path, None, {}) + k1 = _compute_runtime_key(None, None, tmp_path) + k2 = _compute_runtime_key(None, None, tmp_path) assert k1 == k2 def test_key_is_16_chars(self, tmp_path): - key = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) + key = _compute_runtime_key("acme", "u_1", tmp_path) assert len(key) == 16 def test_key_is_hex(self, tmp_path): - key = _compute_runtime_key("acme", "u_1", tmp_path, None, {}) + key = _compute_runtime_key("acme", "u_1", tmp_path) int(key, 16) # raises if not valid hex @@ -85,7 +75,7 @@ def test_server_dir_can_be_none(self): class TestServerManager: async def test_get_or_start_starts_server(self, tmp_path): manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) server = await manager.get_or_start( key=key, project_dir=tmp_path, @@ -103,7 +93,7 @@ async def test_get_or_start_starts_server(self, tmp_path): async def test_same_key_reuses_server(self, tmp_path): """Same key → same server (same port in registry).""" manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) s1 = await manager.get_or_start( key=key, project_dir=tmp_path, @@ -125,8 +115,8 @@ async def test_same_key_reuses_server(self, tmp_path): async def test_different_key_starts_different_server(self, tmp_path): manager = ServerManager() - k1 = _compute_runtime_key("acme", None, tmp_path, None, {}) - k2 = _compute_runtime_key("beta", None, tmp_path, None, {}) + k1 = _compute_runtime_key("acme", None, tmp_path) + k2 = _compute_runtime_key("beta", None, tmp_path) s1 = await manager.get_or_start( key=k1, project_dir=tmp_path, @@ -148,8 +138,8 @@ async def test_different_key_starts_different_server(self, tmp_path): async def test_stop_all_terminates_all(self, tmp_path): manager = ServerManager() - k1 = _compute_runtime_key("acme", None, tmp_path, None, {}) - k2 = _compute_runtime_key("beta", None, tmp_path, None, {}) + k1 = _compute_runtime_key("acme", None, tmp_path) + k2 = _compute_runtime_key("beta", None, tmp_path) e1_before = None e2_before = None await manager.get_or_start( @@ -182,7 +172,7 @@ async def test_stop_all_terminates_all(self, tmp_path): async def test_server_dir_created(self, tmp_path): manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) server_dir = tmp_path / "srv" await manager.get_or_start( key=key, @@ -200,8 +190,8 @@ async def test_server_dir_created(self, tmp_path): async def test_stop_single_server(self, tmp_path): """stop(key) terminates one server, leaves others running.""" manager = ServerManager() - k1 = _compute_runtime_key("acme", None, tmp_path, None, {}) - k2 = _compute_runtime_key("beta", None, tmp_path, None, {}) + k1 = _compute_runtime_key("acme", None, tmp_path) + k2 = _compute_runtime_key("beta", None, tmp_path) await manager.get_or_start( key=k1, project_dir=tmp_path, @@ -240,7 +230,7 @@ class TestServerManagerRegistry: async def test_start_writes_registry_entry(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) server = await manager.get_or_start( key=key, project_dir=tmp_path, @@ -258,7 +248,7 @@ async def test_start_writes_registry_entry(self, tmp_path, monkeypatch): async def test_stop_deletes_registry_entry(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -273,7 +263,7 @@ async def test_stop_deletes_registry_entry(self, tmp_path, monkeypatch): async def test_attaches_to_alive_registry_entry(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager1 = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) s1 = await manager1.get_or_start( key=key, project_dir=tmp_path, @@ -310,7 +300,7 @@ async def test_stale_registry_entry_cleaned_on_attach(self, tmp_path, monkeypatc # Write a stale entry with a dead PID from opencode_runtime.registry import RegistryEntry, now_iso - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) timestamp = now_iso() registry.write( RegistryEntry( @@ -344,7 +334,7 @@ async def test_stale_registry_entry_cleaned_on_attach(self, tmp_path, monkeypatc async def test_start_stores_workspace_and_user_id(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key("org_a", "u_1", tmp_path, None, {}) + key = _compute_runtime_key("org_a", "u_1", tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -365,7 +355,7 @@ async def test_server_restarted_after_external_kill(self, tmp_path, monkeypatch) """If registry entry is deleted externally, next call starts a fresh server.""" monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) s1 = await manager.get_or_start( key=key, project_dir=tmp_path, @@ -405,7 +395,7 @@ class TestServerManagerQuery: async def test_find_returns_entry(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -426,7 +416,7 @@ async def test_find_returns_none_for_missing_key(self, tmp_path, monkeypatch): async def test_is_alive_true_for_running(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -445,8 +435,8 @@ async def test_is_alive_false_for_missing_key(self, tmp_path, monkeypatch): async def test_list_returns_entries_with_liveness(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - k1 = _compute_runtime_key("acme", None, tmp_path, None, {}) - k2 = _compute_runtime_key("beta", None, tmp_path, None, {}) + k1 = _compute_runtime_key("acme", None, tmp_path) + k2 = _compute_runtime_key("beta", None, tmp_path) await manager.get_or_start( key=k1, project_dir=tmp_path, @@ -476,7 +466,7 @@ async def test_list_empty_when_no_servers(self, tmp_path, monkeypatch): async def test_health_returns_healthy(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -499,7 +489,7 @@ async def test_health_raises_for_missing_key(self, tmp_path, monkeypatch): async def test_stop_returns_true_when_alive(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") manager = ServerManager() - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) await manager.get_or_start( key=key, project_dir=tmp_path, @@ -520,7 +510,7 @@ async def test_stop_returns_false_for_dead_process(self, tmp_path, monkeypatch): from opencode_runtime.registry import RegistryEntry, now_iso monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) timestamp = now_iso() registry.write( RegistryEntry( @@ -567,7 +557,7 @@ async def fake_start(_self, *, port, password, **kwargs): async def test_concurrent_get_or_start_calls_start_once(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) calls: list[int] = [] monkeypatch.setattr(ServerManager, "_start", self._fake_start(key, tmp_path, 0.2, calls)) manager = ServerManager() @@ -590,7 +580,7 @@ def request(): async def test_failed_start_deletes_claim_for_retry(self, tmp_path, monkeypatch): monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) calls: list[int] = [] succeeding_start = self._fake_start(key, tmp_path, 0.0, calls) @@ -631,7 +621,7 @@ async def test_losing_caller_raises_when_starter_fails(self, tmp_path, monkeypat """A caller waiting on someone else's claim should fail fast, not hang for the full timeout, once that claim disappears.""" monkeypatch.setattr(registry, "REGISTRY_DIR", tmp_path / "reg") - key = _compute_runtime_key(None, None, tmp_path, None, {}) + key = _compute_runtime_key(None, None, tmp_path) async def always_fails(_self, **kwargs): raise RuntimeError("boom") diff --git a/tests/test_session.py b/tests/test_session.py index 95a5fb5..495dc48 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -48,8 +48,8 @@ async def test_session_config_merged(self, runtime): session = await runtime.session(config={"model": "test/model"}) assert session.config["model"] == "test/model" - async def test_session_materials_override_gets_separate_server(self, tmp_path): - """Per-session materials → different runtime key → different server.""" + async def test_session_materials_override_reuses_server(self, tmp_path): + """Per-session materials → same runtime key → same server (materials are not identity).""" mat_dir = tmp_path / "materials" mat_dir.mkdir() (mat_dir / "AGENTS.md").write_text("# agents") @@ -60,8 +60,8 @@ async def test_session_materials_override_gets_separate_server(self, tmp_path): ) as r: s1 = await r.session() s2 = await r.session(materials=str(mat_dir)) - # Different materials → different key → different server/client - assert s1.raw_client.base_url != s2.raw_client.base_url + # Different materials → same key → same server/client + assert s1.raw_client.base_url == s2.raw_client.base_url class TestOpenCodeSession: