dispatchbox 0.1.1__tar.gz → 0.1.3__tar.gz
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.
- {dispatchbox-0.1.1/dispatchbox.egg-info → dispatchbox-0.1.3}/PKG-INFO +1 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/__init__.py +1 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/hub.py +34 -3
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/server.py +8 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/worker.py +3 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3/dispatchbox.egg-info}/PKG-INFO +1 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/pyproject.toml +1 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_e2e.py +17 -1
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_step0.py +57 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/LICENSE +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/MANIFEST.in +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/README.md +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/__main__.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/chat.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/cron.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/feishu.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/net.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/rules.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/slack.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox/store.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox.egg-info/SOURCES.txt +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox.egg-info/dependency_links.txt +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox.egg-info/entry_points.txt +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox.egg-info/requires.txt +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/dispatchbox.egg-info/top_level.txt +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/examples/hello-codex.json +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/examples/hello.json +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/rules.example.json +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/setup.cfg +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/__init__.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/helpers.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_chat.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_cron_rules.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_hub.py +0 -0
- {dispatchbox-0.1.1 → dispatchbox-0.1.3}/tests/test_net.py +0 -0
|
@@ -231,7 +231,7 @@ class Hub:
|
|
|
231
231
|
|
|
232
232
|
def finish(self, run_id: int, worker: str, result: dict[str, Any]) -> dict[str, Any]:
|
|
233
233
|
with self.lock:
|
|
234
|
-
self._running(run_id, worker)
|
|
234
|
+
row = self._running(run_id, worker)
|
|
235
235
|
exit_code = int(result.get("exit_code", 1))
|
|
236
236
|
output = str(result.get("output", ""))
|
|
237
237
|
summary = result.get("summary") or extract_summary(output)
|
|
@@ -246,19 +246,50 @@ class Hub:
|
|
|
246
246
|
status = "failed"
|
|
247
247
|
else:
|
|
248
248
|
status = "succeeded"
|
|
249
|
+
usage = (_num(result.get("cost_usd"), float), _num(result.get("input_tokens"), int),
|
|
250
|
+
_num(result.get("output_tokens"), int))
|
|
251
|
+
if row["parent_run"] and result.get("session_id"):
|
|
252
|
+
usage = self._own_share(row, str(result["session_id"]), *usage)
|
|
249
253
|
self.db.execute(
|
|
250
254
|
"UPDATE runs SET status=?, finished_at=?, exit_code=?, summary=?, session_id=?, output_tail=?, "
|
|
251
255
|
"decision_request=?, cost_usd=?, input_tokens=?, output_tokens=? WHERE id=?",
|
|
252
256
|
(status, self.clock(), exit_code, (summary or "")[:2000], result.get("session_id"),
|
|
253
257
|
output[-8000:], json.dumps(decision_req, ensure_ascii=False) if decision_req else None,
|
|
254
|
-
|
|
255
|
-
_num(result.get("output_tokens"), int), run_id),
|
|
258
|
+
*usage, run_id),
|
|
256
259
|
)
|
|
257
260
|
self.cv.notify_all()
|
|
258
261
|
run = self.get_run(run_id)
|
|
259
262
|
self._maybe_notify(run)
|
|
260
263
|
return {"run_id": run_id, "status": status}
|
|
261
264
|
|
|
265
|
+
def _own_share(self, row: Any, session_id: str, cost: float | None, tokens_in: int | None,
|
|
266
|
+
tokens_out: int | None) -> tuple[float | None, int | None, int | None]:
|
|
267
|
+
"""A continued session reports running totals: claude's total_cost_usd covers every call of the session so
|
|
268
|
+
far (its token counts cover the call alone), and codex's token counts do the same. Verified with claude 2.1.310
|
|
269
|
+
and codex 0.166. Take off what earlier runs of the same session recorded, so each run holds only its own share.
|
|
270
|
+
A figure below that total is a per-call one and is kept as reported."""
|
|
271
|
+
prev_cost, prev_in, prev_out = 0.0, 0, 0
|
|
272
|
+
rid = row["parent_run"]
|
|
273
|
+
while rid:
|
|
274
|
+
earlier = self.db.execute(
|
|
275
|
+
"SELECT parent_run, session_id, cost_usd, input_tokens, output_tokens FROM runs WHERE id=?", (rid,)
|
|
276
|
+
).fetchone()
|
|
277
|
+
if earlier is None or earlier["session_id"] != session_id:
|
|
278
|
+
break
|
|
279
|
+
prev_cost += earlier["cost_usd"] or 0
|
|
280
|
+
prev_in += earlier["input_tokens"] or 0
|
|
281
|
+
prev_out += earlier["output_tokens"] or 0
|
|
282
|
+
rid = earlier["parent_run"]
|
|
283
|
+
runner = json.loads(row["spec"]).get("runner")
|
|
284
|
+
if runner == "claude" and cost is not None and cost >= prev_cost:
|
|
285
|
+
cost -= prev_cost
|
|
286
|
+
if runner == "codex":
|
|
287
|
+
if tokens_in is not None and tokens_in >= prev_in:
|
|
288
|
+
tokens_in -= prev_in
|
|
289
|
+
if tokens_out is not None and tokens_out >= prev_out:
|
|
290
|
+
tokens_out -= prev_out
|
|
291
|
+
return cost, tokens_in, tokens_out
|
|
292
|
+
|
|
262
293
|
def decide(self, run_id: int, choice: str, note: str = "", by: str = "human") -> dict[str, Any]:
|
|
263
294
|
with self.lock:
|
|
264
295
|
run = self.get_run(run_id)
|
|
@@ -151,7 +151,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
151
151
|
try:
|
|
152
152
|
self._route(method, parts, query)
|
|
153
153
|
except HubError as e:
|
|
154
|
-
if
|
|
154
|
+
if self._wants_json(parts):
|
|
155
155
|
self._json(e.status, {"error": str(e)})
|
|
156
156
|
else:
|
|
157
157
|
self._html(e.status, page("Error", f"<p>{html.escape(str(e))}</p><p><a href='/'>Back</a></p>"))
|
|
@@ -161,6 +161,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
161
161
|
log.exception("request failed")
|
|
162
162
|
self._json(500, {"error": f"internal error: {e.__class__.__name__}"})
|
|
163
163
|
|
|
164
|
+
def _wants_json(self, parts: list[str]) -> bool:
|
|
165
|
+
"""API clients get JSON errors; only a browser using the dashboard gets an HTML page."""
|
|
166
|
+
return (parts[:1] in (["api"], ["events"], ["claim"], ["hooks"])
|
|
167
|
+
or self.headers.get("Accept", "").startswith("application/json")
|
|
168
|
+
or self.headers.get("Content-Type", "").startswith("application/json")
|
|
169
|
+
or self.headers.get("Authorization", "").startswith("Bearer "))
|
|
170
|
+
|
|
164
171
|
def _route(self, method: str, parts: list[str], q: dict[str, str]) -> None:
|
|
165
172
|
if parts == ["healthz"]:
|
|
166
173
|
return self._json(200, {"ok": True})
|
|
@@ -167,6 +167,9 @@ def parse_output(runner: str, stdout: str, stderr: str, tmpdir: str) -> dict[str
|
|
|
167
167
|
data = json.loads(stdout.strip().splitlines()[-1]) if stdout.strip() else {}
|
|
168
168
|
except (json.JSONDecodeError, IndexError):
|
|
169
169
|
data = {}
|
|
170
|
+
if isinstance(data, list):
|
|
171
|
+
# With verbose on, by flag or in its settings, Claude prints every message as one JSON array on one line.
|
|
172
|
+
data = next((m for m in reversed(data) if isinstance(m, dict) and m.get("type") == "result"), {})
|
|
170
173
|
if isinstance(data, dict) and "result" in data:
|
|
171
174
|
out = {"output": str(data.get("result") or ""), "session_id": data.get("session_id")}
|
|
172
175
|
if data.get("is_error"):
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dispatchbox"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Route events to Claude Code and Codex running on your own machines, and ask you only when it matters."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -34,8 +34,9 @@ elif "DELETE" in prompt:
|
|
|
34
34
|
else:
|
|
35
35
|
text = "did the work\nDISPATCHBOX_SUMMARY: reviewed " + os.environ["DISPATCHBOX_RULE"]
|
|
36
36
|
assert "DISPATCHBOX_TOKEN" not in os.environ, "token leaked to agent"
|
|
37
|
+
cost = 0.03 if "--resume" in args else 0.01 # like the real CLI, a resumed session reports its running total
|
|
37
38
|
print(json.dumps({"type": "result", "result": text, "session_id": "sess-42", "is_error": False,
|
|
38
|
-
"total_cost_usd":
|
|
39
|
+
"total_cost_usd": cost, "usage": {"input_tokens": 10, "cache_read_input_tokens": 90,
|
|
39
40
|
"output_tokens": 7}}))
|
|
40
41
|
"""
|
|
41
42
|
|
|
@@ -130,6 +131,19 @@ class E2ETest(unittest.TestCase):
|
|
|
130
131
|
self.assertEqual(http("GET", self.base + "/healthz", token=None)[0], 200)
|
|
131
132
|
self.assertEqual(http("POST", self.base + "/events", {"source": "a"})[0], 400)
|
|
132
133
|
|
|
134
|
+
def test_api_errors_are_json_without_an_accept_header(self):
|
|
135
|
+
no_accept = {"Accept": ""}
|
|
136
|
+
s, body = http("POST", self.base + "/runs/999/decision", {"choice": "x"}, headers=no_accept)
|
|
137
|
+
self.assertEqual(s, 404)
|
|
138
|
+
self.assertEqual(json.loads(body), {"error": "run 999 not found"})
|
|
139
|
+
s, body = http("POST", self.base + "/runs/999/decision", {"choice": "x"}, token=None, headers=no_accept)
|
|
140
|
+
self.assertEqual(s, 401)
|
|
141
|
+
self.assertIn("error", json.loads(body))
|
|
142
|
+
# A browser posting the dashboard form still gets a page.
|
|
143
|
+
s, body = http("POST", self.base + "/runs/999/decision", token=None, form="choice=x",
|
|
144
|
+
headers={"Accept": "text/html"})
|
|
145
|
+
self.assertTrue(body.lstrip().startswith("<!doctype html>"), body[:80])
|
|
146
|
+
|
|
133
147
|
def test_full_loop_with_decision_and_resume(self):
|
|
134
148
|
self.start_worker()
|
|
135
149
|
s, body = http("POST", self.base + "/events", {"source": "github", "type": "pr.opened", "subject": "r#1"})
|
|
@@ -158,6 +172,8 @@ class E2ETest(unittest.TestCase):
|
|
|
158
172
|
follow = [r for r in self.hub.list_runs() if r.get("parent_run") == rid][0]
|
|
159
173
|
run = self.wait_status(follow["id"], {"succeeded", "failed"})
|
|
160
174
|
self.assertEqual(run["summary"], "resumed sess-42")
|
|
175
|
+
self.assertAlmostEqual(run["cost_usd"], 0.02, msg="only this run's share of the session total")
|
|
176
|
+
self.assertEqual((run["input_tokens"], run["output_tokens"]), (100, 7))
|
|
161
177
|
|
|
162
178
|
def test_agent_reported_failure(self):
|
|
163
179
|
self.start_worker()
|
|
@@ -16,8 +16,16 @@ RULES = {
|
|
|
16
16
|
"rules": [
|
|
17
17
|
{"name": "job", "when": {"source": "s"}, "prompt": "do {subject}", "group": "{subject}"},
|
|
18
18
|
{"name": "retry-job", "when": {"source": "r"}, "prompt": "do {subject}", "retries": 1, "notify": "on_failure"},
|
|
19
|
+
{"name": "after", "when": {"source": "dispatchbox", "type": "decision.made"}, "prompt": "go on"},
|
|
19
20
|
]
|
|
20
21
|
}
|
|
22
|
+
CODEX_RULES = {
|
|
23
|
+
"rules": [
|
|
24
|
+
{"name": "job", "when": {"source": "s"}, "runner": "codex", "prompt": "do {subject}"},
|
|
25
|
+
{"name": "after", "when": {"source": "dispatchbox", "type": "decision.made"}, "runner": "codex", "prompt": "go on"},
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
ASK = 'DISPATCHBOX_DECISION: {"question": "Go on?", "options": ["yes", "no"]}'
|
|
21
29
|
|
|
22
30
|
# Shapes copied from real `claude -p --output-format json` and `codex exec --json` output.
|
|
23
31
|
CLAUDE_OUT = json.dumps({"type": "result", "is_error": False, "session_id": "s-1", "total_cost_usd": 0.0024359,
|
|
@@ -104,6 +112,44 @@ class Step0Hub(unittest.TestCase):
|
|
|
104
112
|
self.hub.finish(task["run_id"], "w", {"exit_code": exit_code, "output": output, **extra})
|
|
105
113
|
return self.hub.get_run(task["run_id"])
|
|
106
114
|
|
|
115
|
+
def continue_session(self, run_id, output="done", **extra):
|
|
116
|
+
"""Answer the decision run_id waits for, then finish the follow-up run with the given result."""
|
|
117
|
+
follow = self.hub.decide(run_id, "yes")["follow_up"]["runs"][0]
|
|
118
|
+
task = self.hub.claim("w")
|
|
119
|
+
self.assertEqual((task["run_id"], task["resume_session"]), (follow, self.hub.get_run(run_id)["session_id"]))
|
|
120
|
+
self.hub.finish(follow, "w", {"exit_code": 0, "output": output, **extra})
|
|
121
|
+
return self.hub.get_run(follow)
|
|
122
|
+
|
|
123
|
+
def test_continued_claude_session_records_only_its_own_cost(self):
|
|
124
|
+
# Figures from a real claude 2.1.310 session: the resumed call reports the session's running total cost,
|
|
125
|
+
# while its token counts cover that call alone.
|
|
126
|
+
first = self.run_once(ASK, session_id="s-1", cost_usd=0.72866, input_tokens=36424, output_tokens=4)
|
|
127
|
+
second = self.continue_session(first["id"], ASK, session_id="s-1", cost_usd=0.895048,
|
|
128
|
+
input_tokens=36466, output_tokens=4)
|
|
129
|
+
self.assertAlmostEqual(second["cost_usd"], 0.166388)
|
|
130
|
+
self.assertEqual((second["input_tokens"], second["output_tokens"]), (36466, 4))
|
|
131
|
+
third = self.continue_session(second["id"], session_id="s-1", cost_usd=1.0, input_tokens=100, output_tokens=2)
|
|
132
|
+
self.assertAlmostEqual(third["cost_usd"], 0.104952, msg="the whole chain counts, not just the parent")
|
|
133
|
+
self.assertAlmostEqual(first["cost_usd"], 0.72866, msg="the first run is left alone")
|
|
134
|
+
|
|
135
|
+
def test_continued_codex_session_records_only_its_own_tokens(self):
|
|
136
|
+
# Figures from a real codex 0.166 session: the resumed call reports the session's running token totals.
|
|
137
|
+
self.hub.close()
|
|
138
|
+
self.hub = Hub(write_rules(self.tmp.name, CODEX_RULES), str(Path(self.tmp.name, "c.db")), lost_after=60,
|
|
139
|
+
clock=self.clock)
|
|
140
|
+
first = self.run_once(ASK, session_id="t-1", input_tokens=23372, output_tokens=5)
|
|
141
|
+
second = self.continue_session(first["id"], session_id="t-1", input_tokens=50928, output_tokens=10)
|
|
142
|
+
self.assertEqual((second["input_tokens"], second["output_tokens"]), (27556, 5))
|
|
143
|
+
self.assertIsNone(second["cost_usd"])
|
|
144
|
+
|
|
145
|
+
def test_usage_of_a_new_session_or_below_the_total_is_kept(self):
|
|
146
|
+
first = self.run_once(ASK, session_id="s-1", cost_usd=0.5)
|
|
147
|
+
fresh = self.continue_session(first["id"], session_id="s-2", cost_usd=0.4)
|
|
148
|
+
self.assertAlmostEqual(fresh["cost_usd"], 0.4, msg="a new session owes nothing to the old one")
|
|
149
|
+
first = self.run_once(ASK, session_id="s-3", cost_usd=0.5)
|
|
150
|
+
same = self.continue_session(first["id"], session_id="s-3", cost_usd=0.1)
|
|
151
|
+
self.assertAlmostEqual(same["cost_usd"], 0.1, msg="a figure below the running total is per call")
|
|
152
|
+
|
|
107
153
|
def test_agent_failed_verdict_beats_clean_exit(self):
|
|
108
154
|
run = self.run_once("tried\nDISPATCHBOX_STATUS: failed\nDISPATCHBOX_SUMMARY: permission denied, file kept")
|
|
109
155
|
self.assertEqual(run["status"], "failed")
|
|
@@ -178,6 +224,17 @@ class Step0Units(unittest.TestCase):
|
|
|
178
224
|
self.assertEqual(out["output_tokens"], 4)
|
|
179
225
|
self.assertEqual(out["session_id"], "s-1")
|
|
180
226
|
|
|
227
|
+
def test_parse_claude_verbose_array(self):
|
|
228
|
+
# With verbose on, claude 2.1.310 prints every message as one JSON array on a single line.
|
|
229
|
+
line = json.dumps([{"type": "system", "subtype": "init"}, {"type": "assistant"}, {"type": "rate_limit_event"},
|
|
230
|
+
json.loads(CLAUDE_OUT)])
|
|
231
|
+
with tempfile.TemporaryDirectory() as d:
|
|
232
|
+
out = parse_output("claude", line + "\n", "", d)
|
|
233
|
+
self.assertEqual((out["output"], out["session_id"], out["cost_usd"]), ("ok", "s-1", 0.0024359))
|
|
234
|
+
with tempfile.TemporaryDirectory() as d:
|
|
235
|
+
none = parse_output("claude", '[{"type": "system"}]\n', "", d)
|
|
236
|
+
self.assertNotIn("session_id", none, "an array without a result is read as raw output")
|
|
237
|
+
|
|
181
238
|
def test_parse_codex_success_and_failure(self):
|
|
182
239
|
with tempfile.TemporaryDirectory() as d:
|
|
183
240
|
Path(d, "last_message.txt").write_text("ok\n")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|