fix(opencode): the Linux check fixes for opencode 2.x (Issue #60) #62
No files matched your search
@@ -10,6 +10,15 @@ _LEGACY_TABLES = (("message", "session_id"), ("messages", "session_id"), ("part"
|
|||||||
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
("parts", "session_id"), ("todo", "session_id"), ("session_share", "session_id"),
|
||||||
("session", "id"), ("sessions", "id"))
|
("session", "id"), ("sessions", "id"))
|
||||||
|
|
||||||
|
def _ro_connect(db: str):
|
||||||
|
"""A read that neither creates nor removes anything beside db (Issue #60):
|
||||||
|
the WAL sidecars are used only when both already exist (a live writer
|
||||||
|
keeps them); otherwise the file is read as immutable."""
|
||||||
|
path = os.path.abspath(db)
|
||||||
|
both = os.path.exists(path + "-wal") and os.path.exists(path + "-shm")
|
||||||
|
return sqlite3.connect(f"file:{quote(path)}?mode=ro" + ("" if both else "&immutable=1"), uri=True)
|
||||||
|
|
||||||
|
|
||||||
_PROVIDER_ENV_VARS = (
|
_PROVIDER_ENV_VARS = (
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
'OPENAI_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
@@ -139,7 +148,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
try:
|
try:
|
||||||
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
# opencode 2.x keeps sessions in session_v2 (a 1.x `session` table
|
||||||
# survives a migration but is no longer written).
|
# survives a migration but is no longer written).
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
r = conn.execute("SELECT directory, time_created FROM session_v2 WHERE id=?", (uuid,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not r:
|
if not r:
|
||||||
@@ -202,7 +211,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
|
"""Child sessions (any depth) of uuid in session_v2 and a 1.x session table."""
|
||||||
found, todo = [], [uuid]
|
found, todo = [], [uuid]
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
conn = _ro_connect(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
return found
|
return found
|
||||||
try:
|
try:
|
||||||
@@ -224,7 +233,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
|
|
||||||
def _has_session(self, db: str, uuid: str) -> bool:
|
def _has_session(self, db: str, uuid: str) -> bool:
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
try:
|
try:
|
||||||
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
return conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (uuid,)).fetchone() is not None
|
||||||
finally:
|
finally:
|
||||||
@@ -239,7 +248,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
|
found = scan_names(os.path.join(self._data_home(ctx), "opencode"), uuid)
|
||||||
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
|
for i in [uuid] + (self._descendants(db, uuid) if db and os.path.isfile(db) else []):
|
||||||
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
|
rows = db_rows_naming(db, i, ("session_id", "aggregate_id"),
|
||||||
id_tables=(("session", "id"), ("session_v2", "id")))
|
id_tables=(("session", "id"), ("session_v2", "id")), connect=_ro_connect)
|
||||||
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
found += rows if i == uuid else [f"{r} [child session {i}]" for r in rows]
|
||||||
return found
|
return found
|
||||||
|
|
||||||
@@ -306,7 +315,7 @@ class OpenCodeAgentAdapter(BaseAgentAdapter):
|
|||||||
params.append(int(float(ctx.epoch) * 1000))
|
params.append(int(float(ctx.epoch) * 1000))
|
||||||
query += " ORDER BY time_created DESC LIMIT 20"
|
query += " ORDER BY time_created DESC LIMIT 20"
|
||||||
|
|
||||||
conn = sqlite3.connect(db)
|
conn = _ro_connect(db)
|
||||||
rows = conn.execute(query, params).fetchall()
|
rows = conn.execute(query, params).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|||||||
@@ -108,14 +108,15 @@ def scan_names(root: str, uuid: str, skip=()) -> list:
|
|||||||
return sorted(found)
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=()) -> list:
|
def db_rows_naming(db: str, uuid: str, columns=(), id_tables=(), connect=None) -> list:
|
||||||
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
""""<db> (<table>.<col>: <n> rows)" for every table holding rows of this
|
||||||
conversation in one of `columns`, or in the id column of an `id_tables`
|
conversation in one of `columns`, or in the id column of an `id_tables`
|
||||||
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped."""
|
(table, column) pair (Issue #31). Read-only; unreadable tables are skipped.
|
||||||
|
`connect(db)` opens the database (default: a mode=ro URI)."""
|
||||||
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
if not _plausible_id(uuid) or not db or not os.path.isfile(db):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
conn = connect(db) if connect else sqlite3.connect(f"file:{quote(os.path.abspath(db))}?mode=ro", uri=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
found = []
|
found = []
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
1. The 2.x home screen's placeholder (`Ask anything… "<example>"`, cut
|
1. The 2.x home screen's placeholder (`Ask anything… "<example>"`, cut
|
||||||
without its closing quote in a narrow box) is not typed input.
|
without its closing quote in a narrow box) is not typed input.
|
||||||
|
5. The adapter's queries neither create nor remove anything beside
|
||||||
|
opencode's database (a read-write open checkpoints the WAL and deletes
|
||||||
|
the sidecars on close; a plain mode=ro open creates missing ones).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +25,7 @@ if str(SKILLS) not in sys.path:
|
|||||||
sys.path.insert(0, str(SKILLS))
|
sys.path.insert(0, str(SKILLS))
|
||||||
|
|
||||||
from lib_py import stall_watch # noqa: E402
|
from lib_py import stall_watch # noqa: E402
|
||||||
|
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
||||||
from lib_py.agents.input_region import extract_input_region # noqa: E402
|
from lib_py.agents.input_region import extract_input_region # noqa: E402
|
||||||
from lib_py.agents.registry import get_adapter # noqa: E402
|
from lib_py.agents.registry import get_adapter # noqa: E402
|
||||||
|
|
||||||
@@ -80,3 +87,81 @@ def test_stall_watch_sees_the_2x_home_box_empty(name, monkeypatch):
|
|||||||
def test_other_adapters_have_no_placeholder_pattern():
|
def test_other_adapters_have_no_placeholder_pattern():
|
||||||
for agent in ("claude", "agy", "grok", "codex", "hermes"):
|
for agent in ("claude", "agy", "grok", "codex", "hermes"):
|
||||||
assert get_adapter(agent).input_placeholder_pattern is None, agent
|
assert get_adapter(agent).input_placeholder_pattern is None, agent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. the queries leave the files beside the database alone
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
|
||||||
|
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
|
||||||
|
|
||||||
|
QUERIES = {
|
||||||
|
"verify_artifact": (lambda a, ctx, db: a.verify_artifact("ses_main", ctx), True),
|
||||||
|
"discover": (lambda a, ctx, db: a.discover(ctx), ["ses_main"]),
|
||||||
|
"_has_session": (lambda a, ctx, db: a._has_session(db, "ses_main"), True),
|
||||||
|
"_descendants": (lambda a, ctx, db: a._descendants(db, "ses_main"), ["ses_child"]),
|
||||||
|
"residual_artifacts": (lambda a, ctx, db: any("session_v2.id: 1 rows" in r
|
||||||
|
for r in a.residual_artifacts("ses_main", ctx)), True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wal_writer(db: Path, ws: Path) -> sqlite3.Connection:
|
||||||
|
"""An open WAL connection holding the schema and two sessions: until it
|
||||||
|
closes, the rows live in the -wal only."""
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA journal_mode=wal")
|
||||||
|
for stmt in V2_DDL:
|
||||||
|
conn.execute(stmt)
|
||||||
|
conn.execute("INSERT INTO session_v2 VALUES ('ses_main', NULL, ?, 2000000)", (str(ws),))
|
||||||
|
conn.execute("INSERT INTO session_v2 VALUES ('ses_child', 'ses_main', ?, 3000000)", (str(ws),))
|
||||||
|
conn.execute("INSERT INTO session_message VALUES ('msg-1', 'ses_main')")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wal_home(tmp_path, monkeypatch):
|
||||||
|
"""(adapter, ctx, db, ws, make(state)) - make builds the database in one
|
||||||
|
of three states: `clean` (closed: no sidecars), `leftover` (both sidecars
|
||||||
|
left behind by a writer that is gone, the rows only in the -wal),
|
||||||
|
`writer` (a live writer whose rows are only in the -wal)."""
|
||||||
|
home, ws = tmp_path / "home", tmp_path / "ws"
|
||||||
|
ws.mkdir()
|
||||||
|
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
||||||
|
db.parent.mkdir(parents=True)
|
||||||
|
adapter = get_adapter("opencode")
|
||||||
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: None) # the default path, no subprocess
|
||||||
|
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def make(state):
|
||||||
|
if state == "clean":
|
||||||
|
_wal_writer(db, ws).close()
|
||||||
|
elif state == "leftover":
|
||||||
|
src = tmp_path / "src" / "opencode.db"
|
||||||
|
src.parent.mkdir()
|
||||||
|
conn = _wal_writer(src, ws)
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
shutil.copyfile(str(src) + suffix, str(db) + suffix)
|
||||||
|
conn.close()
|
||||||
|
else:
|
||||||
|
opened.append(_wal_writer(db, ws))
|
||||||
|
return sorted(os.listdir(db.parent))
|
||||||
|
|
||||||
|
yield adapter, ctx, str(db), ws, make
|
||||||
|
for conn in opened:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("query", sorted(QUERIES))
|
||||||
|
@pytest.mark.parametrize("state,listing", [
|
||||||
|
("clean", ["opencode.db"]),
|
||||||
|
("leftover", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
|
||||||
|
("writer", ["opencode.db", "opencode.db-shm", "opencode.db-wal"]),
|
||||||
|
])
|
||||||
|
def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, listing, query):
|
||||||
|
adapter, ctx, db, ws, make = wal_home
|
||||||
|
assert make(state) == listing
|
||||||
|
run, expected = QUERIES[query]
|
||||||
|
assert run(adapter, ctx, db) == expected
|
||||||
|
assert sorted(os.listdir(os.path.dirname(db))) == listing
|
||||||
Reference in new issue
Block a user