baserun-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
baserun_cli/runner.py ADDED
@@ -0,0 +1,319 @@
1
+ """Task runner: receives a task envelope, drives the CLIAgentConnector, and
2
+ publishes parsed events back to the run:{run_id} channel.
3
+
4
+ Translates the vendored ConnectorEvent stream into the wire protocol:
5
+
6
+ {"type":"result","data":{"run_id":"...","seq":N,"finished":false,
7
+ "kind":"session|thinking|tool_call|tool_result|message|final|error|usage",
8
+ "content":{...}}}
9
+
10
+ Terminal frame: kind=="final", finished==true.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import logging
17
+ import os
18
+ import time
19
+ from typing import Any
20
+
21
+ from ._vendored import (
22
+ CLIAgentConnector,
23
+ ConnectorEvent,
24
+ ConnectorEventType,
25
+ get_connector,
26
+ )
27
+ from .channel import ChannelClient
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ # connector event type → wire kind
32
+ _KIND_MAP = {
33
+ ConnectorEventType.SESSION: "session",
34
+ ConnectorEventType.THINKING: "thinking",
35
+ ConnectorEventType.TOOL_CALL: "tool_call",
36
+ ConnectorEventType.TOOL_RESULT: "tool_result",
37
+ ConnectorEventType.MESSAGE: "message",
38
+ ConnectorEventType.FINAL: "final",
39
+ ConnectorEventType.ERROR: "error",
40
+ ConnectorEventType.USAGE: "usage",
41
+ }
42
+
43
+ # run_id dedup persistence — survives process restart
44
+ _RECENT_TTL = 86400 # 24h
45
+ _MAX_RECENT = 50 # max entries (oldest trimmed)
46
+
47
+
48
+ def _state_file(app_id: str) -> str:
49
+ """Per-agent state file: ~/.lark-agent-hub-runs-{app_id}.json"""
50
+ safe_id = app_id.replace("/", "_")
51
+ return os.path.join(os.path.expanduser("~"), f".lark-agent-hub-runs-{safe_id}.json")
52
+
53
+
54
+ # State entry: {"ts": float, "status": "done"|"error", "seq": int}
55
+ # - ts: completion timestamp (for TTL expiry + ordering)
56
+ # - status: terminal kind ("done" = final, "error" = error frame)
57
+ # - seq: last published event seq (ordering, similar to nchan message_id)
58
+ RecentEntry = dict[str, Any] # {"ts": float, "status": str, "seq": int}
59
+
60
+
61
+ def _load_recent(app_id: str) -> dict[str, RecentEntry]:
62
+ path = _state_file(app_id)
63
+ try:
64
+ with open(path) as f:
65
+ data = json.load(f)
66
+ now = time.time()
67
+ # filter expired + migrate old format ({rid: float} → {rid: {ts, status, seq}})
68
+ result: dict[str, RecentEntry] = {}
69
+ for rid, val in data.items():
70
+ if isinstance(val, (int, float)):
71
+ # old format: {rid: timestamp}
72
+ ts = float(val)
73
+ elif isinstance(val, dict):
74
+ ts = float(val.get("ts", 0))
75
+ else:
76
+ continue
77
+ if now - ts < _RECENT_TTL:
78
+ if isinstance(val, dict):
79
+ result[rid] = val
80
+ else:
81
+ result[rid] = {"ts": ts, "status": "done", "seq": 0}
82
+ return result
83
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
84
+ return {}
85
+
86
+
87
+ def _save_recent(app_id: str, recent: dict[str, RecentEntry]) -> None:
88
+ path = _state_file(app_id)
89
+ # trim to max entries (keep most recent by ts)
90
+ if len(recent) > _MAX_RECENT:
91
+ sorted_items = sorted(recent.items(), key=lambda x: x[1].get("ts", 0), reverse=True)
92
+ recent = dict(sorted_items[:_MAX_RECENT])
93
+ try:
94
+ with open(path, "w") as f:
95
+ json.dump(recent, f)
96
+ except OSError as e:
97
+ log.warning("failed to persist run history: %s", e)
98
+
99
+
100
+ class TaskRunner:
101
+ def __init__(self, channel: ChannelClient, concurrency: int = 1, app_id: str = "") -> None:
102
+ self.channel = channel
103
+ self._sem = asyncio.Semaphore(max(1, concurrency))
104
+ self._active_runs: set[str] = set()
105
+ self._app_id = app_id or getattr(channel, "app_id", "")
106
+ self._recent_runs: dict[str, RecentEntry] = _load_recent(self._app_id)
107
+ if self._recent_runs:
108
+ log.info("loaded %d recent run_ids from %s", len(self._recent_runs), _state_file(self._app_id))
109
+
110
+ def _cleanup_recent(self) -> None:
111
+ now = time.time()
112
+ expired = [rid for rid, entry in self._recent_runs.items()
113
+ if now - entry.get("ts", 0) > _RECENT_TTL]
114
+ for rid in expired:
115
+ del self._recent_runs[rid]
116
+ if expired:
117
+ _save_recent(self._app_id, self._recent_runs)
118
+
119
+ async def handle(self, envelope: dict[str, Any]) -> None:
120
+ """envelope = {"type":"task","data":{run_id, mode, prompt, ...}}"""
121
+ data = envelope.get("data") or envelope
122
+ run_id = data.get("run_id", "")
123
+
124
+ self._cleanup_recent()
125
+
126
+ if run_id in self._active_runs:
127
+ log.warning("duplicate task for run %s (already running), skipping", run_id)
128
+ return
129
+ if run_id in self._recent_runs:
130
+ log.warning("duplicate task for run %s (recently completed), skipping", run_id)
131
+ return
132
+
133
+ self._active_runs.add(run_id)
134
+ terminal_published = False
135
+ try:
136
+ async with self._sem:
137
+ terminal_published, status, last_seq = await self._run_task(data)
138
+ finally:
139
+ self._active_runs.discard(run_id)
140
+ # 只在 terminal 确实发布后才记入去重表
141
+ if terminal_published:
142
+ self._recent_runs[run_id] = {
143
+ "ts": time.time(),
144
+ "status": status,
145
+ "seq": last_seq,
146
+ }
147
+ _save_recent(self._app_id, self._recent_runs)
148
+
149
+ async def _run_task(self, data: dict[str, Any]) -> tuple[bool, str, int]:
150
+ """Execute a run. Returns (terminal_published, status, last_seq).
151
+
152
+ Events are persisted to a local JSONL file as they are produced.
153
+ After the connector finishes, any events that failed to publish
154
+ via WS are retried via HTTP to guarantee delivery.
155
+ """
156
+ run_id = data["run_id"]
157
+ mode = data.get("mode", "new")
158
+ prompt = data["prompt"]
159
+ agent_session_id = data.get("agent_session_id")
160
+ connector_type = data.get("connector_type", "claude_code")
161
+ config = data.get("config") or {}
162
+
163
+ connector = get_connector(connector_type, config)
164
+ log.info("run %s mode=%s connector=%s session_id=%s", run_id, mode, connector.spec.bin, agent_session_id or "new")
165
+
166
+ events_iter = self._select_events(connector, mode, prompt, agent_session_id)
167
+
168
+ # pre-connect WS publisher (don't wait for first event — avoids 10s+ delay
169
+ # where early events accumulate and arrive as a burst, killing streaming UX)
170
+ asyncio.create_task(self.channel.ensure_run_pub(run_id))
171
+
172
+ # local persistence: raw event log for debugging + retry
173
+ log_dir = os.path.join(os.path.expanduser("~"), ".lark-agent-hub-logs")
174
+ os.makedirs(log_dir, exist_ok=True)
175
+ log_path = os.path.join(log_dir, f"{run_id}.jsonl")
176
+
177
+ seq = 0
178
+ saw_terminal = False
179
+ terminal_status = "done"
180
+
181
+ try:
182
+ with open(log_path, "w") as log_file:
183
+ async for ev in events_iter:
184
+ seq += 1
185
+ kind = _KIND_MAP.get(ev.type, "message")
186
+ is_terminal = ev.type in (ConnectorEventType.FINAL, ConnectorEventType.ERROR)
187
+
188
+ # build wire payload
189
+ payload = {
190
+ "type": "result",
191
+ "data": {
192
+ "run_id": run_id,
193
+ "seq": seq,
194
+ "finished": is_terminal,
195
+ "kind": kind,
196
+ "content": ev.payload,
197
+ },
198
+ }
199
+
200
+ # persist locally
201
+ log_file.write(json.dumps(payload["data"], ensure_ascii=False) + "\n")
202
+ log_file.flush()
203
+
204
+ log.debug("run %s event seq=%d kind=%s", run_id, seq, kind)
205
+
206
+ # publish
207
+ await self.channel.publish_event(run_id, payload)
208
+
209
+ if is_terminal:
210
+ saw_terminal = True
211
+ terminal_status = kind
212
+ log.info("run %s terminal event (kind=%s, seq=%d)", run_id, kind, seq)
213
+
214
+ if not saw_terminal:
215
+ seq += 1
216
+ payload = {
217
+ "type": "result",
218
+ "data": {
219
+ "run_id": run_id,
220
+ "seq": seq,
221
+ "finished": True,
222
+ "kind": "final",
223
+ "content": {},
224
+ },
225
+ }
226
+ log_file.write(json.dumps(payload["data"], ensure_ascii=False) + "\n")
227
+ log_file.flush()
228
+ ok = await self.channel.publish_event(run_id, payload)
229
+ if not ok:
230
+ failed_events.append(payload)
231
+ log.info("run %s synthesized terminal (seq=%d)", run_id, seq)
232
+
233
+ # verify-and-replay:对比 nchan 实际状态,精确补发缺失事件
234
+ all_local = []
235
+ try:
236
+ with open(log_path) as f:
237
+ for line in f:
238
+ line = line.strip()
239
+ if line:
240
+ all_local.append({"type": "result", "data": json.loads(line)})
241
+ except Exception:
242
+ pass
243
+
244
+ if all_local:
245
+ await self.channel.verify_and_replay(run_id, all_local)
246
+
247
+ log.info("run %s completed (%d events, log: %s)", run_id, seq, log_path)
248
+ return True, terminal_status, seq
249
+
250
+ except Exception as e:
251
+ log.exception("run %s failed at seq=%d", run_id, seq)
252
+ # publish error terminal via HTTP (reliable)
253
+ seq += 1
254
+ error_payload = {
255
+ "type": "result",
256
+ "data": {
257
+ "run_id": run_id,
258
+ "seq": seq,
259
+ "finished": True,
260
+ "kind": "error",
261
+ "content": {"message": str(e)},
262
+ },
263
+ }
264
+ try:
265
+ with open(log_path, "a") as log_file:
266
+ log_file.write(json.dumps(error_payload["data"], ensure_ascii=False) + "\n")
267
+ except Exception:
268
+ pass
269
+ await self.channel.publish_event(run_id, error_payload)
270
+ return True, "error", seq
271
+
272
+ finally:
273
+ await self.channel.close_run_pub(run_id)
274
+
275
+ def _select_events(
276
+ self,
277
+ connector: CLIAgentConnector,
278
+ mode: str,
279
+ prompt: str,
280
+ agent_session_id: str | None,
281
+ ):
282
+ if mode == "resume" and agent_session_id:
283
+ return connector.resume(agent_session_id, prompt)
284
+ if mode == "fork" and agent_session_id:
285
+ return connector.fork(agent_session_id, prompt)
286
+ return connector.new_session(prompt)
287
+
288
+ async def _publish_event(
289
+ self, run_id: str, seq: int, ev: ConnectorEvent, finished: bool
290
+ ) -> None:
291
+ kind = _KIND_MAP.get(ev.type, "message")
292
+ await self.channel.publish_event(
293
+ run_id,
294
+ {
295
+ "type": "result",
296
+ "data": {
297
+ "run_id": run_id,
298
+ "seq": seq,
299
+ "finished": finished,
300
+ "kind": kind,
301
+ "content": ev.payload,
302
+ },
303
+ },
304
+ )
305
+
306
+ async def _publish_terminal(self, run_id: str, seq: int) -> None:
307
+ await self.channel.publish_event(
308
+ run_id,
309
+ {
310
+ "type": "result",
311
+ "data": {
312
+ "run_id": run_id,
313
+ "seq": seq,
314
+ "finished": True,
315
+ "kind": "final",
316
+ "content": {},
317
+ },
318
+ },
319
+ )
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: baserun-cli
3
+ Version: 0.1.0
4
+ Summary: BaseRun agent-side daemon (connects to nchan, spawns CLI agents, publishes run events)
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: websockets>=13.0
8
+ Requires-Dist: httpx>=0.27.0
9
+
10
+ # baserun-cli
11
+
12
+ BaseRun agent-side daemon. It connects to the BaseRun nchan channel, runs local CLI agents, and publishes run events back to BaseRun.
13
+
14
+ ## Run
15
+
16
+ ```bash
17
+ NCHAN_URL="wss://baserun.livesig.cn/nchan" \
18
+ AGENT_APP_ID="<agent_id>" \
19
+ AGENT_APP_SECRET="<agent_secret>" \
20
+ CONNECTOR_TYPE="claude_code" \
21
+ uvx baserun-cli
22
+ ```
23
+
24
+ `CONNECTOR_TYPE` defaults to `claude_code`.
25
+
26
+ ## Build
27
+
28
+ ```bash
29
+ cd baserun-cli
30
+ rm -rf dist build *.egg-info
31
+ python3 -m build
32
+ ```
33
+
34
+ ## Publish
35
+
36
+ ```bash
37
+ cd baserun-cli
38
+ python3 -m twine upload dist/*
39
+ ```
40
+
41
+ Or with uv:
42
+
43
+ ```bash
44
+ cd baserun-cli
45
+ uv build
46
+ uv publish
47
+ ```
@@ -0,0 +1,17 @@
1
+ baserun_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ baserun_cli/channel.py,sha256=zExZ6FhhnW5Q_XCEcPfoGexarMTss0V-yjRODL78egs,14706
3
+ baserun_cli/main.py,sha256=gwF0UWym75L0m6vtKH0rigDsxfQcG5aODcw08yTMv6o,3247
4
+ baserun_cli/runner.py,sha256=1XPA2Kg7RKmVkcnb9uEY1g8VZSyptI1hGsupMa2ImAY,11889
5
+ baserun_cli/_vendored/__init__.py,sha256=p7Gs6VZV1iCsdT4rUYAVGHtEdFDryf0bKmSQ8n2T66w,714
6
+ baserun_cli/_vendored/base.py,sha256=R1MW9Ele_RVM6RNRzRsJ2YytVTex4u0IEKQ5k6QPlx8,9349
7
+ baserun_cli/_vendored/cli.py,sha256=uM-PcCibLFG4JFUa0p84V3HJ7TMR8rXDG1dCT9mJQcc,14484
8
+ baserun_cli/_vendored/parsers/__init__.py,sha256=EfwdkaVGDooMD6Z_oToY2g80TTl-luHy8KraMmU2QJs,1235
9
+ baserun_cli/_vendored/parsers/base.py,sha256=zLlWiGyqaHIfCkMQQzb3P1HmM0UjIB2oYtidYERzx9g,1636
10
+ baserun_cli/_vendored/parsers/bash_agent.py,sha256=KoQ2rh7L646I2KYL2uW5kbjju_G5kmsjK0mEx9ov3Wk,3055
11
+ baserun_cli/_vendored/parsers/claude.py,sha256=TiSrIto_2NgkRGwiMIEx7InGdod3XOlVTiA-hRjNxRo,4102
12
+ baserun_cli/_vendored/parsers/codex.py,sha256=F4mU1w5kzvX53znxaAdKgsT2ooOdT8U9FQq025lKp6c,7065
13
+ baserun_cli-0.1.0.dist-info/METADATA,sha256=CmWiQFcvK2jK_tsL4nLWOPoKtPwdrZCoKTfxxrFQ6vc,864
14
+ baserun_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ baserun_cli-0.1.0.dist-info/entry_points.txt,sha256=FCe2sionsoqBvcQwHFbs7A3g23wbS0OUXIUPKxgPfXk,59
16
+ baserun_cli-0.1.0.dist-info/top_level.txt,sha256=sG8tqnc3iyQdtUhIVjvYGxo9cmDLsuwnMU7tlCCteGw,12
17
+ baserun_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ baserun-cli = baserun_cli.main:main_sync
@@ -0,0 +1 @@
1
+ baserun_cli