dispatchbox 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.
@@ -0,0 +1,3 @@
1
+ """dispatchbox: a thin event router that hands work to coding agents on your own machines."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,127 @@
1
+ """Command line entry point: `python -m dispatchbox hub|worker|send|check`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import os
9
+ import socket
10
+ import sys
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ from . import __version__
15
+ from .net import context_for
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ p = argparse.ArgumentParser(prog="dispatchbox", description="Route events to coding agents on your own machines.")
20
+ p.add_argument("--version", action="version", version=__version__)
21
+ p.add_argument("-v", "--verbose", action="store_true")
22
+ sub = p.add_subparsers(dest="cmd", required=True)
23
+
24
+ h = sub.add_parser("hub", help="run the hub (events in, tasks out)")
25
+ h.add_argument("--rules", default="rules.json")
26
+ h.add_argument("--db", default="dispatchbox.db")
27
+ h.add_argument("--host", default="127.0.0.1")
28
+ h.add_argument("--port", type=int, default=8787)
29
+ h.add_argument("--public-url", default=os.environ.get("DISPATCHBOX_PUBLIC_URL", ""),
30
+ help="URL people use to reach the hub, used in notification links")
31
+
32
+ w = sub.add_parser("worker", help="run a worker that pulls tasks and runs agents")
33
+ w.add_argument("--hub", default=os.environ.get("DISPATCHBOX_HUB", "http://127.0.0.1:8787"))
34
+ w.add_argument("--name", default=socket.gethostname().split(".")[0])
35
+ w.add_argument("--slots", type=int, default=2, help="how many runs at once")
36
+ w.add_argument("--allow-dir", action="append", dest="allow_dirs",
37
+ help="directory runs may use (repeatable); defaults to the current directory")
38
+ w.add_argument("--runners", default="claude,codex", help="comma list; add 'shell' to allow shell rules")
39
+ w.add_argument("--claude-bin", default="claude")
40
+ w.add_argument("--codex-bin", default="codex")
41
+
42
+ s = sub.add_parser("send", help="send one event to the hub")
43
+ s.add_argument("source")
44
+ s.add_argument("type")
45
+ s.add_argument("--subject", default="")
46
+ s.add_argument("--summary", default="")
47
+ s.add_argument("--link", default="")
48
+ s.add_argument("--payload", default="{}", help="JSON object")
49
+ s.add_argument("--hub", default=os.environ.get("DISPATCHBOX_HUB", "http://127.0.0.1:8787"))
50
+
51
+ c = sub.add_parser("check", help="validate a rules file and show which rule an event would hit")
52
+ c.add_argument("--rules", default="rules.json")
53
+ c.add_argument("--source")
54
+ c.add_argument("--type")
55
+ c.add_argument("--subject", default="")
56
+
57
+ args = p.parse_args(argv)
58
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
59
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s")
60
+ token = os.environ.get("DISPATCHBOX_TOKEN", "")
61
+
62
+ if args.cmd == "hub":
63
+ from .hub import Hub
64
+ from .server import serve
65
+ from .chat import ChatError, adapters_from_env
66
+ hub = Hub(args.rules, args.db, public_url=args.public_url)
67
+ try:
68
+ hub.adapters = adapters_from_env(hub, dict(os.environ))
69
+ except ChatError as e:
70
+ print(f"Cannot start: {e}", file=sys.stderr)
71
+ return 2
72
+ srv = serve(hub, args.host, args.port, token, os.environ.get("DISPATCHBOX_GITHUB_SECRET", ""))
73
+ logging.getLogger("dispatchbox").info("hub listening on http://%s:%d with %d rules, %d schedules, chat: %s",
74
+ args.host, args.port, len(hub.config.rules), len(hub.config.schedules),
75
+ ", ".join(hub.adapters) or "none")
76
+ try:
77
+ srv.serve_forever()
78
+ except KeyboardInterrupt:
79
+ pass
80
+ return 0
81
+
82
+ if args.cmd == "worker":
83
+ from .worker import WorkerConfig, serve_worker
84
+ if not token:
85
+ print("Set DISPATCHBOX_TOKEN to the hub's token.", file=sys.stderr)
86
+ return 2
87
+ cfg = WorkerConfig(hub=args.hub, name=args.name, token=token, slots=args.slots,
88
+ allow_dirs=args.allow_dirs or [os.getcwd()],
89
+ runners=[r.strip() for r in args.runners.split(",") if r.strip()],
90
+ claude_bin=args.claude_bin, codex_bin=args.codex_bin)
91
+ try:
92
+ serve_worker(cfg)
93
+ except KeyboardInterrupt:
94
+ pass
95
+ return 0
96
+
97
+ if args.cmd == "send":
98
+ body = {"source": args.source, "type": args.type, "subject": args.subject, "summary": args.summary,
99
+ "link": args.link, "payload": json.loads(args.payload)}
100
+ req = urllib.request.Request(args.hub.rstrip("/") + "/events", data=json.dumps(body).encode(), method="POST",
101
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
102
+ try:
103
+ with urllib.request.urlopen(req, timeout=15, context=context_for(req.full_url)) as resp:
104
+ print(resp.read().decode())
105
+ except urllib.error.HTTPError as e:
106
+ print(f"{e.code}: {e.read().decode()}", file=sys.stderr)
107
+ return 1
108
+ return 0
109
+
110
+ if args.cmd == "check":
111
+ from .rules import load_config_file, normalize_event
112
+ try:
113
+ cfg = load_config_file(args.rules)
114
+ except Exception as e:
115
+ print(f"invalid: {e}", file=sys.stderr)
116
+ return 1
117
+ print(f"ok: {len(cfg.rules)} rules, {len(cfg.schedules)} schedules, dedupe window {cfg.dedupe_window}s")
118
+ if args.source and args.type:
119
+ ev = normalize_event({"source": args.source, "type": args.type, "subject": args.subject})
120
+ hits = cfg.match(ev)
121
+ print("matches: " + (", ".join(r.name for r in hits) if hits else "(none)"))
122
+ return 0
123
+ return 1
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
dispatchbox/chat.py ADDED
@@ -0,0 +1,209 @@
1
+ """Shared plumbing for chat platforms (Feishu, Slack).
2
+
3
+ An adapter does four things:
4
+ 1. turns chat messages that mention the bot (or DM it) into dispatchbox events,
5
+ 2. posts a decision card with one button per option when a run needs a human,
6
+ 3. records the button click as a decision and updates the card in place,
7
+ 4. replies in the original thread when a run that came from chat finishes.
8
+
9
+ Platform specifics (auth, signatures, payload shapes, card formats) live in feishu.py and slack.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import threading
17
+ import urllib.error
18
+ import urllib.request
19
+ from typing import TYPE_CHECKING, Any, Callable
20
+
21
+ from .hub import DECISION_MARK, STATUS_MARK, SUMMARY_MARK
22
+ from .net import ssl_context
23
+
24
+ if TYPE_CHECKING:
25
+ from .hub import Hub
26
+
27
+ log = logging.getLogger("dispatchbox.chat")
28
+
29
+ STATUS_LABEL = {
30
+ "succeeded": "✅ succeeded",
31
+ "failed": "❌ failed",
32
+ "lost": "⚠️ lost",
33
+ "awaiting_decision": "🟠 needs your decision",
34
+ "decided": "☑️ decided",
35
+ }
36
+
37
+ # (status, headers, body) returned to the HTTP layer
38
+ Response = tuple[int, dict[str, str], bytes]
39
+
40
+
41
+ def json_response(obj: Any, status: int = 200) -> Response:
42
+ return status, {"Content-Type": "application/json"}, json.dumps(obj, ensure_ascii=False).encode()
43
+
44
+
45
+ def empty_response(status: int = 200) -> Response:
46
+ return status, {}, b""
47
+
48
+
49
+ class ChatError(Exception):
50
+ pass
51
+
52
+
53
+ def http_json(method: str, url: str, body: Any = None, headers: dict[str, str] | None = None,
54
+ timeout: float = 10) -> dict[str, Any]:
55
+ data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
56
+ req = urllib.request.Request(url, data=data, method=method,
57
+ headers={"Content-Type": "application/json; charset=utf-8", **(headers or {})})
58
+ try:
59
+ with urllib.request.urlopen(req, timeout=timeout, context=ssl_context()) as resp:
60
+ raw = resp.read()
61
+ except urllib.error.HTTPError as e:
62
+ raw = e.read()
63
+ try:
64
+ return json.loads(raw)
65
+ except json.JSONDecodeError:
66
+ raise ChatError(f"{method} {url} -> HTTP {e.code}: {raw[:300]!r}") from e
67
+ return json.loads(raw) if raw else {}
68
+
69
+
70
+ def in_background(fn: Callable[..., Any], *args: Any) -> None:
71
+ """Chat platforms want an answer within ~3 seconds, so slow work (API calls) runs on a thread."""
72
+ def run() -> None:
73
+ try:
74
+ fn(*args)
75
+ except Exception:
76
+ log.exception("background chat task failed")
77
+ threading.Thread(target=run, daemon=True).start()
78
+
79
+
80
+ class ChatAdapter:
81
+ name = "chat" # also the event source name and the URL suffix: /hooks/<name>
82
+
83
+ def __init__(self, hub: "Hub"):
84
+ self.hub = hub
85
+
86
+ # ---- configuration from the "chat" section of the rules file, re-read on every use so edits apply live
87
+
88
+ @property
89
+ def settings(self) -> dict[str, Any]:
90
+ # Chat requests are checked before ingest, so pick up allowlist edits here instead of
91
+ # waiting for the once-a-minute cron tick.
92
+ self.hub.reload_if_changed()
93
+ return (self.hub.config.chat or {}).get(self.name) or {}
94
+
95
+ def user_allowed(self, user_id: str, key: str = "allowed_users") -> bool:
96
+ """Nobody is allowed unless listed. "*" allows everyone in the workspace."""
97
+ allowed = self.settings.get(key)
98
+ if allowed is None and key == "approvers":
99
+ allowed = self.settings.get("allowed_users")
100
+ allowed = allowed or []
101
+ return "*" in allowed or user_id in allowed
102
+
103
+ # ---- hooks called by the hub
104
+
105
+ def notify(self, run: dict[str, Any], link: str) -> None:
106
+ raise NotImplementedError
107
+
108
+ def run_finished(self, run: dict[str, Any], link: str) -> None:
109
+ """Reply in the originating thread. Called for every run that reaches a resting state."""
110
+ raise NotImplementedError
111
+
112
+ def handle(self, headers: dict[str, str], body: bytes) -> Response:
113
+ """Handle a request to /hooks/<name>."""
114
+ raise NotImplementedError
115
+
116
+ # ---- helpers shared by adapters
117
+
118
+ def origin(self, run: dict[str, Any]) -> dict[str, Any] | None:
119
+ """The chat message this run (or the run it continues) came from, if it came from this platform."""
120
+ found = self._chat_origin(run, {self.name})
121
+ return found[1] if found else None
122
+
123
+ def from_chat(self, run: dict[str, Any]) -> bool:
124
+ """True if the run came from any connected chat platform. That platform answers in the
125
+ original conversation, so the others stay quiet instead of offering a second set of buttons."""
126
+ return self._chat_origin(run, set(self.hub.adapters) | {self.name}) is not None
127
+
128
+ def _chat_origin(self, run: dict[str, Any], sources: set[str]) -> tuple[str, dict[str, Any]] | None:
129
+ seen: set[int] = set()
130
+ cur: dict[str, Any] | None = run
131
+ while cur and cur["id"] not in seen:
132
+ seen.add(cur["id"])
133
+ for e in cur.get("events") or []:
134
+ if e["source"] in sources and isinstance(e.get("payload"), dict) and e["payload"].get("chat"):
135
+ return e["source"], e["payload"]
136
+ parent = cur.get("parent_run") or cur.get("retry_of")
137
+ cur = self.hub.get_run(parent) if parent else None
138
+ return None
139
+
140
+ def decide_from_chat(self, run_id: int, choice: str, user_id: str) -> tuple[bool, str, dict[str, Any] | None]:
141
+ """Record a button click. Returns (ok, message for the clicker, run after the attempt)."""
142
+ from .hub import HubError
143
+ if not self.user_allowed(user_id, "approvers"):
144
+ log.warning("%s user %s tried to decide run %s but is not an approver", self.name, user_id, run_id)
145
+ return False, "You are not allowed to decide dispatchbox runs.", self.hub.get_run(run_id)
146
+ try:
147
+ self.hub.decide(run_id, choice, by=f"{self.name}:{user_id}")
148
+ except HubError as e:
149
+ return False, str(e), self.hub.get_run(run_id)
150
+ return True, f"Recorded: {choice}", self.hub.get_run(run_id)
151
+
152
+
153
+ def decision_text(run: dict[str, Any]) -> tuple[str, str, list[str]]:
154
+ """(question, context, options) for a run awaiting a decision."""
155
+ req = run.get("decision_request") or {}
156
+ return str(req.get("question") or "The agent needs a decision."), str(req.get("context") or ""), \
157
+ [str(o) for o in (req.get("options") or [])][:5]
158
+
159
+
160
+ def answer_text(run: dict[str, Any], limit: int) -> str:
161
+ """What the agent actually said, minus the DISPATCHBOX_* contract lines; the summary only if that is empty.
162
+ The answer is in the asker's language and has the detail; the summary is a one-liner for lists."""
163
+ lines = [l for l in (run.get("output_tail") or "").splitlines()
164
+ if not l.strip().startswith((DECISION_MARK, STATUS_MARK, SUMMARY_MARK))]
165
+ text = "\n".join(lines).strip() or str(run.get("summary") or "")
166
+ return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
167
+
168
+
169
+ def status_line(run: dict[str, Any]) -> str:
170
+ return f"{STATUS_LABEL.get(run['status'], run['status'])} · run #{run['id']} · {run['rule']}"
171
+
172
+
173
+ def decided_line(run: dict[str, Any]) -> str:
174
+ d = run.get("decision") or {}
175
+ who = str(d.get("by", "")).split(":", 1)[-1]
176
+ return f"Decision: {d.get('choice', '')}" + (f" (by {who})" if who else "")
177
+
178
+
179
+ def adapters_from_env(hub: "Hub", env: dict[str, str]) -> dict[str, ChatAdapter]:
180
+ """Build the adapters whose credentials are present. A half-configured platform is an error, not a skip."""
181
+ adapters: dict[str, ChatAdapter] = {}
182
+
183
+ slack_keys = ("DISPATCHBOX_SLACK_BOT_TOKEN", "DISPATCHBOX_SLACK_SIGNING_SECRET")
184
+ have = [k for k in slack_keys if env.get(k)]
185
+ if have and len(have) != len(slack_keys):
186
+ missing = ", ".join(k for k in slack_keys if not env.get(k))
187
+ raise ChatError(f"Slack is half configured: set {missing} as well, or unset {', '.join(have)}")
188
+ if have:
189
+ from .slack import SlackAdapter
190
+ adapters["slack"] = SlackAdapter(hub, env["DISPATCHBOX_SLACK_BOT_TOKEN"], env["DISPATCHBOX_SLACK_SIGNING_SECRET"],
191
+ api_base=env.get("DISPATCHBOX_SLACK_API", "https://slack.com/api"))
192
+
193
+ feishu_keys = ("DISPATCHBOX_FEISHU_APP_ID", "DISPATCHBOX_FEISHU_APP_SECRET", "DISPATCHBOX_FEISHU_VERIFICATION_TOKEN")
194
+ have = [k for k in feishu_keys if env.get(k)]
195
+ if have and len(have) != len(feishu_keys):
196
+ missing = ", ".join(k for k in feishu_keys if not env.get(k))
197
+ raise ChatError(f"Feishu is half configured: set {missing} as well, or unset {', '.join(have)}")
198
+ if have:
199
+ from .feishu import API_BASE, TOKEN_URL, FeishuAdapter
200
+ adapters["feishu"] = FeishuAdapter(
201
+ hub, env["DISPATCHBOX_FEISHU_APP_ID"], env["DISPATCHBOX_FEISHU_APP_SECRET"], env["DISPATCHBOX_FEISHU_VERIFICATION_TOKEN"],
202
+ encrypt_key=env.get("DISPATCHBOX_FEISHU_ENCRYPT_KEY", ""),
203
+ api_base=env.get("DISPATCHBOX_FEISHU_API", API_BASE), token_url=env.get("DISPATCHBOX_FEISHU_TOKEN_URL", TOKEN_URL))
204
+
205
+ for name in adapters:
206
+ if not (hub.config.chat or {}).get(name):
207
+ log.warning("%s credentials are set but the rules file has no chat.%s section, so nobody is allowed "
208
+ "to use it yet", name, name)
209
+ return adapters
dispatchbox/cron.py ADDED
@@ -0,0 +1,56 @@
1
+ """Minimal 5-field cron matcher: minute hour day-of-month month day-of-week.
2
+
3
+ Supports `*`, `*/n`, `a`, `a-b`, `a-b/n` and comma lists. Day-of-week 0 and 7 are Sunday.
4
+ When both day fields are restricted, a time matches if either one matches (classic cron rule).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+
11
+ _RANGES = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 7)]
12
+
13
+
14
+ def _parse_field(text: str, lo: int, hi: int) -> set[int]:
15
+ values: set[int] = set()
16
+ for part in text.split(","):
17
+ step = 1
18
+ if "/" in part:
19
+ part, step_s = part.split("/", 1)
20
+ step = int(step_s)
21
+ if step <= 0:
22
+ raise ValueError(f"bad step in {text!r}")
23
+ if part == "*":
24
+ start, end = lo, hi
25
+ elif "-" in part:
26
+ a, b = part.split("-", 1)
27
+ start, end = int(a), int(b)
28
+ else:
29
+ start = int(part)
30
+ end = hi if step != 1 else start
31
+ if start < lo or end > hi or start > end:
32
+ raise ValueError(f"value out of range in {text!r}")
33
+ values.update(range(start, end + 1, step))
34
+ return values
35
+
36
+
37
+ class Cron:
38
+ def __init__(self, expr: str):
39
+ fields = expr.split()
40
+ if len(fields) != 5:
41
+ raise ValueError(f"cron needs 5 fields, got {expr!r}")
42
+ self.expr = expr
43
+ parsed = [_parse_field(f, lo, hi) for f, (lo, hi) in zip(fields, _RANGES)]
44
+ self.minutes, self.hours, self.days, self.months, dows = parsed
45
+ self.dows = {d % 7 for d in dows}
46
+ self.day_restricted = fields[2] != "*"
47
+ self.dow_restricted = fields[4] != "*"
48
+
49
+ def matches(self, t: datetime) -> bool:
50
+ if t.minute not in self.minutes or t.hour not in self.hours or t.month not in self.months:
51
+ return False
52
+ dom_ok = t.day in self.days
53
+ dow_ok = (t.isoweekday() % 7) in self.dows
54
+ if self.day_restricted and self.dow_restricted:
55
+ return dom_ok or dow_ok
56
+ return dom_ok and dow_ok