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.
dispatchbox/rules.py ADDED
@@ -0,0 +1,236 @@
1
+ """Rules file loading, event normalisation, matching and templating."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .cron import Cron
14
+
15
+ RUNNERS = {"claude", "codex", "shell"}
16
+ EVENT_FIELDS = ("source", "type", "subject", "summary", "link")
17
+
18
+ _DURATION = re.compile(r"^(\d+)\s*([smhd]?)$")
19
+ _TEMPLATE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_.]*)\}")
20
+
21
+
22
+ def parse_duration(value: Any, default: int) -> int:
23
+ """Turn 90, "90", "90s", "20m", "2h" or "1d" into seconds."""
24
+ if value is None:
25
+ return default
26
+ if isinstance(value, (int, float)):
27
+ return int(value)
28
+ m = _DURATION.match(str(value).strip())
29
+ if not m:
30
+ raise ValueError(f"bad duration {value!r}")
31
+ n, unit = int(m.group(1)), m.group(2) or "s"
32
+ return n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
33
+
34
+
35
+ def render(template: str, ctx: dict[str, Any]) -> str:
36
+ """Replace {field} and {payload.a.b} with values from ctx; unknown names are left as-is."""
37
+
38
+ def repl(m: re.Match[str]) -> str:
39
+ cur: Any = ctx
40
+ for key in m.group(1).split("."):
41
+ if isinstance(cur, dict) and key in cur:
42
+ cur = cur[key]
43
+ else:
44
+ return m.group(0)
45
+ if isinstance(cur, (dict, list)):
46
+ return json.dumps(cur, ensure_ascii=False)
47
+ return "" if cur is None else str(cur)
48
+
49
+ return _TEMPLATE.sub(repl, template)
50
+
51
+
52
+ def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
53
+ if not isinstance(raw, dict):
54
+ raise ValueError("event must be a JSON object")
55
+ for key in ("source", "type"):
56
+ if not isinstance(raw.get(key), str) or not raw[key].strip():
57
+ raise ValueError(f"event.{key} is required")
58
+ event = {k: (str(raw[k]) if raw.get(k) is not None else "") for k in EVENT_FIELDS}
59
+ payload = raw.get("payload", {})
60
+ event["payload"] = payload if payload is not None else {}
61
+ key = raw.get("dedupe_key")
62
+ if not key:
63
+ if event["subject"]:
64
+ key = f"{event['source']}:{event['type']}:{event['subject']}"
65
+ else:
66
+ blob = json.dumps({k: event[k] for k in (*EVENT_FIELDS, "payload")}, sort_keys=True, ensure_ascii=False)
67
+ key = f"{event['source']}:{event['type']}:sha1:{hashlib.sha1(blob.encode()).hexdigest()[:16]}"
68
+ event["dedupe_key"] = str(key)
69
+ return event
70
+
71
+
72
+ def _as_list(value: Any) -> list[str]:
73
+ if value is None:
74
+ return []
75
+ if isinstance(value, str):
76
+ return [value]
77
+ return [str(v) for v in value]
78
+
79
+
80
+ @dataclass
81
+ class Rule:
82
+ name: str
83
+ sources: list[str]
84
+ types: list[str]
85
+ subjects: list[str]
86
+ worker: str
87
+ runner: str
88
+ cwd: str
89
+ prompt: str
90
+ model: str | None
91
+ skill: str | None
92
+ group: str
93
+ concurrency: int
94
+ coalesce: bool
95
+ timeout: int
96
+ notify: str
97
+ args: list[str]
98
+ command: str | None
99
+ cont: bool
100
+ retries: int = 0
101
+
102
+ def matches(self, event: dict[str, Any]) -> bool:
103
+ def ok(patterns: list[str], value: str) -> bool:
104
+ return not patterns or any(fnmatch.fnmatchcase(value, p) for p in patterns)
105
+
106
+ return ok(self.sources, event["source"]) and ok(self.types, event["type"]) and ok(self.subjects, event["subject"])
107
+
108
+ def to_spec(self) -> dict[str, Any]:
109
+ return {
110
+ "rule": self.name,
111
+ "runner": self.runner,
112
+ "cwd": self.cwd,
113
+ "prompt": self.prompt,
114
+ "model": self.model,
115
+ "skill": self.skill,
116
+ "timeout": self.timeout,
117
+ "notify": self.notify,
118
+ "args": self.args,
119
+ "command": self.command,
120
+ "retries": self.retries,
121
+ }
122
+
123
+
124
+ @dataclass
125
+ class Schedule:
126
+ name: str
127
+ cron: Cron
128
+ event: dict[str, Any]
129
+
130
+
131
+ @dataclass
132
+ class Config:
133
+ rules: list[Rule]
134
+ schedules: list[Schedule]
135
+ dedupe_window: int
136
+ memory: list[str] = field(default_factory=list)
137
+ require_decision_for: list[str] = field(default_factory=list)
138
+ chat: dict[str, Any] = field(default_factory=dict)
139
+
140
+ def match(self, event: dict[str, Any]) -> list[Rule]:
141
+ hits: list[Rule] = []
142
+ for rule in self.rules:
143
+ if rule.matches(event):
144
+ hits.append(rule)
145
+ if not rule.cont:
146
+ break
147
+ return hits
148
+
149
+
150
+ def _build_rule(raw: dict[str, Any], defaults: dict[str, Any]) -> Rule:
151
+ merged = {**defaults, **raw}
152
+ name = merged.get("name")
153
+ if not name:
154
+ raise ValueError("every rule needs a name")
155
+ when = merged.get("when", {}) or {}
156
+ runner = merged.get("runner", "claude")
157
+ if runner not in RUNNERS:
158
+ raise ValueError(f"rule {name!r}: runner must be one of {sorted(RUNNERS)}")
159
+ if runner == "shell" and not merged.get("command"):
160
+ raise ValueError(f"rule {name!r}: shell runner needs a command")
161
+ if runner != "shell" and not merged.get("prompt"):
162
+ raise ValueError(f"rule {name!r}: {runner} runner needs a prompt")
163
+ concurrency = int(merged.get("concurrency", 1))
164
+ if concurrency < 1:
165
+ raise ValueError(f"rule {name!r}: concurrency must be >= 1")
166
+ retries = int(merged.get("retries", 0))
167
+ if retries < 0:
168
+ raise ValueError(f"rule {name!r}: retries must be >= 0")
169
+ notify = merged.get("notify", "on_decision")
170
+ if notify not in {"never", "on_decision", "on_failure", "always"}:
171
+ raise ValueError(f"rule {name!r}: bad notify {notify!r}")
172
+ return Rule(
173
+ name=name,
174
+ sources=_as_list(when.get("source")),
175
+ types=_as_list(when.get("types", when.get("type"))),
176
+ subjects=_as_list(when.get("subject")),
177
+ worker=str(merged.get("worker", "*")),
178
+ runner=runner,
179
+ cwd=str(merged.get("cwd", ".")),
180
+ prompt=str(merged.get("prompt", "")),
181
+ model=merged.get("model"),
182
+ skill=merged.get("skill"),
183
+ group=str(merged.get("group", "{rule}:{subject}")),
184
+ concurrency=concurrency,
185
+ coalesce=bool(merged.get("coalesce", True)),
186
+ timeout=parse_duration(merged.get("timeout"), 1800),
187
+ notify=notify,
188
+ args=[str(a) for a in merged.get("args", [])],
189
+ command=merged.get("command"),
190
+ cont=bool(merged.get("continue", False)),
191
+ retries=retries,
192
+ )
193
+
194
+
195
+ def load_config(data: dict[str, Any]) -> Config:
196
+ defaults = data.get("defaults", {}) or {}
197
+ rules = [_build_rule(r, defaults) for r in data.get("rules", [])]
198
+ names = [r.name for r in rules]
199
+ dupes = {n for n in names if names.count(n) > 1}
200
+ if dupes:
201
+ raise ValueError(f"duplicate rule names: {sorted(dupes)}")
202
+ schedules = []
203
+ for s in data.get("schedules", []):
204
+ if not s.get("name") or not s.get("cron"):
205
+ raise ValueError("every schedule needs a name and a cron expression")
206
+ event = {"source": "cron", "type": s["name"], "subject": s["name"], **(s.get("event") or {})}
207
+ schedules.append(Schedule(name=s["name"], cron=Cron(s["cron"]), event=event))
208
+ return Config(
209
+ rules=rules,
210
+ schedules=schedules,
211
+ dedupe_window=parse_duration(data.get("dedupe_window"), 600),
212
+ memory=_as_list(data.get("memory")),
213
+ require_decision_for=_as_list(data.get("require_decision_for")),
214
+ chat=_load_chat(data.get("chat")),
215
+ )
216
+
217
+
218
+ def _load_chat(raw: Any) -> dict[str, Any]:
219
+ if raw is None:
220
+ return {}
221
+ if not isinstance(raw, dict):
222
+ raise ValueError("chat must be an object")
223
+ for platform, conf in raw.items():
224
+ if platform not in {"feishu", "slack"}:
225
+ raise ValueError(f"chat.{platform}: only feishu and slack are supported")
226
+ if not isinstance(conf, dict):
227
+ raise ValueError(f"chat.{platform} must be an object")
228
+ for key in ("allowed_users", "approvers"):
229
+ if key in conf and not (isinstance(conf[key], list) and all(isinstance(u, str) for u in conf[key])):
230
+ raise ValueError(f"chat.{platform}.{key} must be a list of user ids")
231
+ return raw
232
+
233
+
234
+ def load_config_file(path: str | Path) -> Config:
235
+ with open(path, encoding="utf-8") as f:
236
+ return load_config(json.load(f))