Skip to content

Commit b20e108

Browse files
committed
fix(openclaw): address PR review follow-ups
1 parent f58bc2d commit b20e108

12 files changed

Lines changed: 284 additions & 33 deletions

File tree

reflexio/cli/commands/setup_cmd.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,19 @@ def _remove_env_keys(env_path: Path, keys: tuple[str, ...]) -> None:
598598
env_path.write_text("\n".join(kept) + ("\n" if kept else ""))
599599

600600

601+
def _read_env_key(env_path: Path | None, key: str) -> str | None:
602+
"""Read a simple KEY=value assignment from a .env file."""
603+
if env_path is None or not env_path.exists():
604+
return None
605+
prefix = f"{key}="
606+
for line in env_path.read_text().splitlines():
607+
stripped = line.strip()
608+
if not stripped or stripped.startswith("#") or not stripped.startswith(prefix):
609+
continue
610+
return stripped[len(prefix) :].strip().strip('"').strip("'") or None
611+
return None
612+
613+
601614
def _run_smart_install(plugin_dir: Path) -> None:
602615
"""Run the plugin's first-run installer so uv/.venv land before first hook."""
603616
script = plugin_dir / "scripts" / "smart-install.sh"
@@ -676,7 +689,7 @@ def _install_openclaw_integration(env_path: Path) -> bool:
676689
# this, the gateway silently drops every plugin-side hook dispatch
677690
# with: '[plugins] typed hook "agent_end" blocked because non-bundled
678691
# plugins must set ...hooks.allowConversationAccess=true'.
679-
subprocess.run(
692+
access_cfg = subprocess.run(
680693
[
681694
cli,
682695
"config",
@@ -688,6 +701,12 @@ def _install_openclaw_integration(env_path: Path) -> bool:
688701
capture_output=True,
689702
text=True,
690703
)
704+
if access_cfg.returncode != 0:
705+
typer.echo(
706+
"Error: could not persist openClaw conversation-access permission: "
707+
f"{access_cfg.stderr or access_cfg.stdout}"
708+
)
709+
raise typer.Exit(1)
691710
except subprocess.CalledProcessError as exc:
692711
typer.echo(f"Error: openclaw command failed: {exc.stderr or exc.stdout}")
693712
raise typer.Exit(1) from exc
@@ -733,7 +752,7 @@ def _uninstall_openclaw(env_path: Path | None = None, purge: bool = False) -> No
733752
"This will remove the Reflexio integration from openClaw. Continue?",
734753
abort=True,
735754
)
736-
cli = shutil.which("openclaw")
755+
cli = _read_env_key(env_path, "OPENCLAW_BIN") or shutil.which("openclaw")
737756
if cli:
738757
subprocess.run(
739758
[cli, "plugins", "disable", _OPENCLAW_PLUGIN_ID],
@@ -748,7 +767,10 @@ def _uninstall_openclaw(env_path: Path | None = None, purge: bool = False) -> No
748767
text=True,
749768
)
750769
else:
751-
typer.echo("Warning: openclaw CLI not found on PATH, skipping plugin removal")
770+
typer.echo(
771+
"Warning: openclaw CLI not found in OPENCLAW_BIN or PATH, "
772+
"skipping plugin removal"
773+
)
752774

753775
if env_path is not None:
754776
_remove_env_keys(env_path, ("OPENCLAW_BIN", "OPENCLAW_SMART_USE_LOCAL_CLI"))
@@ -832,6 +854,13 @@ def openclaw(
832854
typer.echo("Error: could not locate or create a .env file")
833855
raise typer.Exit(1)
834856

857+
if repair and (uninstall or purge):
858+
typer.echo("Error: --repair cannot be combined with --uninstall or --purge")
859+
raise typer.Exit(1)
860+
if purge and not uninstall:
861+
typer.echo("Error: --purge requires --uninstall")
862+
raise typer.Exit(1)
863+
835864
if repair:
836865
_repair_openclaw()
837866
return

reflexio/integrations/openclaw/plugin/scripts/backend-service.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
4949

5050
# Pin the openclaw CLI explicitly so the reflexio backend's openclaw_provider
5151
# can find it from a hook context whose PATH lacks the user's normal CLI dir.
52-
if [ -z "${OPENCLAW_SMART_CLI_PATH:-}" ]; then
52+
if [ -z "${OPENCLAW_BIN:-}" ]; then
5353
if _oc_cli_path=$(command -v openclaw 2>/dev/null) && [ -n "$_oc_cli_path" ]; then
54-
export OPENCLAW_SMART_CLI_PATH="$_oc_cli_path"
54+
export OPENCLAW_BIN="$_oc_cli_path"
5555
elif [ -x "$HOME/.local/bin/openclaw" ]; then
56-
export OPENCLAW_SMART_CLI_PATH="$HOME/.local/bin/openclaw"
56+
export OPENCLAW_BIN="$HOME/.local/bin/openclaw"
5757
fi
5858
unset _oc_cli_path
5959
fi

reflexio/integrations/openclaw/plugin/src/openclaw_smart/cli.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,24 +523,26 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
523523
)
524524
return stop_rc or 1
525525

526+
clear_all_failed = False
526527
removed_targets = 0
527528
try:
528529
for target in targets:
529530
if _remove_clear_all_target(target):
530531
removed_targets += 1
531532
except (OSError, _ClearAllError) as exc:
532533
sys.stderr.write(f"error: could not remove reflexio data: {exc}\n")
533-
return 1
534+
clear_all_failed = True
534535

535536
removed_buffers = 0
536537
root = state.state_dir()
537-
if root.is_dir():
538+
if not clear_all_failed and root.is_dir():
538539
for buf in root.glob("*.jsonl"):
539540
try:
540541
buf.unlink()
541542
removed_buffers += 1
542543
except OSError as exc:
543544
sys.stderr.write(f"warning: could not remove {buf}: {exc}\n")
545+
clear_all_failed = True
544546

545547
start_rc = 0
546548
if was_running:
@@ -556,7 +558,7 @@ def cmd_clear_all(args: argparse.Namespace) -> int:
556558
f"Cleared reflexio: {target_summary}. "
557559
f"Removed {removed_buffers} local session buffer(s).\n"
558560
)
559-
return start_rc or 0
561+
return start_rc or (1 if clear_all_failed else 0)
560562

561563

562564
def _build_parser() -> argparse.ArgumentParser:

reflexio/integrations/openclaw/plugin/src/openclaw_smart/publish.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,37 @@
88

99
from __future__ import annotations
1010

11+
import contextlib
12+
from collections.abc import Iterator
1113
from typing import Literal
1214

1315
from openclaw_smart import state
1416
from openclaw_smart.reflexio_adapter import Adapter
1517

18+
try:
19+
import fcntl # POSIX only — Windows falls back to no publish lock.
20+
except ImportError: # pragma: no cover — non-POSIX platforms
21+
fcntl = None # type: ignore[assignment]
22+
1623
PublishStatus = Literal["nothing", "ok", "failed"]
1724

1825

26+
@contextlib.contextmanager
27+
def _session_publish_lock(session_id: str) -> Iterator[None]:
28+
"""Serialize read-publish-watermark for one session buffer."""
29+
lock_path = state.publish_lock_path(session_id)
30+
if lock_path is None or fcntl is None:
31+
yield
32+
return
33+
lock_path.parent.mkdir(parents=True, exist_ok=True)
34+
with lock_path.open("a", encoding="utf-8") as fh:
35+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
36+
try:
37+
yield
38+
finally:
39+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
40+
41+
1942
def publish_unpublished(
2043
*,
2144
session_id: str,
@@ -54,19 +77,20 @@ def publish_unpublished(
5477
was unreachable. On ``"failed"`` the watermark is not advanced,
5578
so the next hook retries the same batch.
5679
"""
57-
records = state.read_all(session_id)
58-
_, interactions = state.unpublished_slice(records)
59-
if not interactions:
60-
return ("nothing", 0)
61-
client = adapter if adapter is not None else Adapter()
62-
ok = client.publish(
63-
session_id=session_id,
64-
project_id=project_id,
65-
interactions=interactions,
66-
force_extraction=force_extraction,
67-
skip_aggregation=skip_aggregation,
68-
)
69-
if ok:
70-
state.append(session_id, {"published_up_to": len(records)})
71-
return ("ok", len(interactions))
72-
return ("failed", len(interactions))
80+
with _session_publish_lock(session_id):
81+
records = state.read_all(session_id)
82+
_, interactions = state.unpublished_slice(records)
83+
if not interactions:
84+
return ("nothing", 0)
85+
client = adapter if adapter is not None else Adapter()
86+
ok = client.publish(
87+
session_id=session_id,
88+
project_id=project_id,
89+
interactions=interactions,
90+
force_extraction=force_extraction,
91+
skip_aggregation=skip_aggregation,
92+
)
93+
if ok:
94+
state.append(session_id, {"published_up_to": len(records)})
95+
return ("ok", len(interactions))
96+
return ("failed", len(interactions))

reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
import logging
2121
import os
2222
import re
23+
from collections.abc import Iterable
2324
from pathlib import Path
24-
from typing import Any, Iterable
25+
from typing import Any
2526

2627
try:
2728
import fcntl # POSIX only — Windows hooks fall back to append-without-lock.
@@ -115,6 +116,14 @@ def injected_path(session_id: str) -> Path | None:
115116
return state_dir() / f"{sid}.injected.jsonl"
116117

117118

119+
def publish_lock_path(session_id: str) -> Path | None:
120+
"""Return the per-session publish lock path, or ``None`` if unsafe."""
121+
sid = _safe_session_id(session_id)
122+
if sid is None:
123+
return None
124+
return state_dir() / f"{sid}.publish.lock"
125+
126+
118127
def append_injected(session_id: str, entries: Iterable[dict[str, Any]]) -> None:
119128
"""Append citation-registry entries to the per-session injected-items file.
120129
@@ -264,15 +273,18 @@ def unpublished_slice(
264273
turns: list[dict[str, Any]] = []
265274
for idx, rec in enumerate(records):
266275
if "published_up_to" in rec:
267-
published = rec["published_up_to"]
276+
marker = rec.get("published_up_to")
277+
if isinstance(marker, int) and marker >= 0:
278+
published = marker
268279
pending_tools = []
269280
turns = []
270281
continue
271282
if idx < published:
272283
continue
273284
role = rec.get("role")
274285
if role == "Assistant_tool":
275-
tool_input = rec.get("tool_input") or {}
286+
raw_tool_input = rec.get("tool_input")
287+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
276288
tool_output = rec.get("tool_output") or ""
277289
tool_entry: dict[str, Any] = {
278290
"tool_name": rec.get("tool_name", ""),

reflexio/integrations/openclaw/plugin/tests/integration/test_publish_to_local_reflexio_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Integration: publish buffered turns to a local SQLite-backed reflexio.
22
33
Requires a reflexio backend reachable at ``REFLEXIO_URL`` (default
4-
``http://localhost:8071/``). Skipped automatically when the backend is
4+
``http://localhost:8081/``). Skipped automatically when the backend is
55
unreachable so the suite is portable across machines.
66
"""
77

@@ -16,7 +16,7 @@
1616

1717

1818
def _reflexio_url() -> str:
19-
return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
19+
return os.environ.get("REFLEXIO_URL", "http://localhost:8081/")
2020

2121

2222
def _backend_alive(url: str) -> bool:

reflexio/integrations/openclaw/plugin/tests/integration/test_search_inject_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525

2626
def _reflexio_url() -> str:
27-
return os.environ.get("REFLEXIO_URL", "http://localhost:8071/")
27+
return os.environ.get("REFLEXIO_URL", "http://localhost:8081/")
2828

2929

3030
def _backend_alive(url: str) -> bool:

reflexio/integrations/openclaw/plugin/tests/test_cli.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
from __future__ import annotations
44

55
from argparse import Namespace
6-
from unittest.mock import MagicMock, patch
6+
from unittest.mock import patch
77

88
import pytest
9-
109
from openclaw_smart import cli
1110

1211

@@ -127,6 +126,32 @@ def test_cmd_clear_all_with_yes_proceeds(monkeypatch, tmp_path):
127126
assert not (sessions / "old.jsonl").exists()
128127

129128

129+
def test_cmd_clear_all_restarts_backend_after_delete_failure(tmp_path):
130+
target = cli._ClearAllTarget(
131+
path=tmp_path / "reflexio-openclaw-test",
132+
kind="dir",
133+
label="test target",
134+
)
135+
service_calls: list[str] = []
136+
137+
def fake_run_service(_script, command) -> int: # noqa: ANN001
138+
service_calls.append(command)
139+
return 0
140+
141+
with patch(
142+
"openclaw_smart.cli._resolve_clear_all_targets", return_value=[target]
143+
), patch("openclaw_smart.cli._service_status", return_value="running on 8071"), patch(
144+
"openclaw_smart.cli._remove_clear_all_target",
145+
side_effect=cli._ClearAllError("boom"),
146+
), patch(
147+
"openclaw_smart.cli._run_service", side_effect=fake_run_service
148+
):
149+
rc = cli.cmd_clear_all(Namespace(yes=True))
150+
151+
assert rc == 1
152+
assert service_calls == ["stop", "start"]
153+
154+
130155
def test_build_parser_accepts_show():
131156
parser = cli._build_parser()
132157
args = parser.parse_args(["show"])

reflexio/integrations/openclaw/plugin/tests/test_events_session_end.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,9 @@ def test_handle_falls_back_to_session_id_key():
3333
return_value="proj-y",
3434
):
3535
session_end.handle({"sessionId": "s2"})
36+
pub.publish_unpublished.assert_called_once()
3637
kwargs = pub.publish_unpublished.call_args[1]
3738
assert kwargs["session_id"] == "s2"
39+
assert kwargs["project_id"] == "proj-y"
40+
assert kwargs["force_extraction"] is True
41+
assert kwargs["skip_aggregation"] is False
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Tests for openclaw_smart.publish."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
from openclaw_smart import publish, state
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def isolate_state_dir(monkeypatch, tmp_path):
13+
sessions = tmp_path / "sessions"
14+
monkeypatch.setenv("OPENCLAW_SMART_STATE_DIR", str(sessions))
15+
return sessions
16+
17+
18+
class _Adapter:
19+
def __init__(self) -> None:
20+
self.calls = 0
21+
22+
def publish(self, **_kwargs) -> bool: # noqa: ANN003
23+
self.calls += 1
24+
return True
25+
26+
27+
def test_publish_unpublished_serializes_with_lock_and_stamps_watermark(
28+
isolate_state_dir,
29+
):
30+
state.append("s1", {"role": "User", "content": "hi"})
31+
adapter = _Adapter()
32+
33+
status, count = publish.publish_unpublished(
34+
session_id="s1",
35+
project_id="proj",
36+
force_extraction=False,
37+
skip_aggregation=False,
38+
adapter=adapter,
39+
)
40+
41+
assert (status, count) == ("ok", 1)
42+
assert adapter.calls == 1
43+
assert (isolate_state_dir / "s1.publish.lock").exists()
44+
records = [
45+
json.loads(line)
46+
for line in (isolate_state_dir / "s1.jsonl").read_text().splitlines()
47+
]
48+
assert records[-1] == {"published_up_to": 1}
49+

0 commit comments

Comments
 (0)