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/hub.py ADDED
@@ -0,0 +1,550 @@
1
+ """Hub logic: ingest events, route them to runs, hand runs to workers, collect results.
2
+
3
+ This module has no HTTP code so it can be tested directly; see server.py for the HTTP layer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ import urllib.request
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ from . import store
20
+ from .net import ssl_context
21
+ from .rules import Config, load_config_file, normalize_event, render
22
+
23
+ log = logging.getLogger("dispatchbox.hub")
24
+
25
+ MAX_PAYLOAD_IN_PROMPT = 4000
26
+ DECISION_MARK = "DISPATCHBOX_DECISION:"
27
+ SUMMARY_MARK = "DISPATCHBOX_SUMMARY:"
28
+ STATUS_MARK = "DISPATCHBOX_STATUS:"
29
+ TERMINAL = {"succeeded", "failed", "lost", "decided"}
30
+
31
+
32
+ class HubError(Exception):
33
+ def __init__(self, status: int, message: str):
34
+ super().__init__(message)
35
+ self.status = status
36
+
37
+
38
+ class Hub:
39
+ def __init__(
40
+ self,
41
+ rules_path: str | Path,
42
+ db_path: str = "dispatchbox.db",
43
+ lost_after: int = 120,
44
+ public_url: str = "",
45
+ clock: Callable[[], float] = time.time,
46
+ ):
47
+ self.rules_path = Path(rules_path)
48
+ self.lost_after = lost_after
49
+ self.public_url = public_url.rstrip("/")
50
+ self.clock = clock
51
+ self.lock = threading.RLock()
52
+ self.cv = threading.Condition(self.lock)
53
+ self.db = store.connect(db_path)
54
+ self._rules_mtime = 0.0
55
+ self.config: Config = load_config_file(self.rules_path)
56
+ self._rules_mtime = self.rules_path.stat().st_mtime
57
+ self._last_cron_minute: str | None = None
58
+ self.notifier: Callable[[dict[str, Any], str], None] = self._default_notify
59
+ self.adapters: dict[str, Any] = {} # name -> ChatAdapter, see chat.py
60
+ self.background: Callable[..., None] = _background
61
+
62
+ def close(self) -> None:
63
+ with self.lock:
64
+ self.db.close()
65
+
66
+ # ------------------------------------------------------------------ config
67
+
68
+ def reload_if_changed(self) -> None:
69
+ try:
70
+ mtime = self.rules_path.stat().st_mtime
71
+ except FileNotFoundError:
72
+ return
73
+ if mtime == self._rules_mtime:
74
+ return
75
+ try:
76
+ cfg = load_config_file(self.rules_path)
77
+ except Exception as e: # keep the last good config
78
+ log.error("rules file invalid, keeping previous version: %s", e)
79
+ self._rules_mtime = mtime
80
+ return
81
+ with self.lock:
82
+ self.config = cfg
83
+ self._rules_mtime = mtime
84
+ log.info("rules reloaded: %d rules, %d schedules", len(cfg.rules), len(cfg.schedules))
85
+
86
+ # ------------------------------------------------------------------ ingest
87
+
88
+ def ingest(self, raw: dict[str, Any], parent_run: int | None = None) -> dict[str, Any]:
89
+ try:
90
+ event = normalize_event(raw)
91
+ except ValueError as e:
92
+ raise HubError(400, str(e)) from e
93
+ self.reload_if_changed()
94
+ now = self.clock()
95
+ with self.lock:
96
+ cfg = self.config
97
+ dup = self.db.execute(
98
+ "SELECT id FROM events WHERE dedupe_key=? AND received_at>=? AND status!='duplicate' ORDER BY id DESC LIMIT 1",
99
+ (event["dedupe_key"], now - max(cfg.dedupe_window, _num(raw.get("dedupe_window"), int) or 0)),
100
+ ).fetchone()
101
+ if dup:
102
+ eid = self._insert_event(event, now, "duplicate", f"duplicate of event {dup['id']}")
103
+ return {"event_id": eid, "status": "duplicate", "duplicate_of": dup["id"], "runs": []}
104
+
105
+ rules = cfg.match(event)
106
+ if not rules:
107
+ eid = self._insert_event(event, now, "unmatched")
108
+ return {"event_id": eid, "status": "unmatched", "runs": []}
109
+
110
+ eid = self._insert_event(event, now, "routed")
111
+ ctx = {**event, "rule": ""}
112
+ run_ids: list[int] = []
113
+ coalesced: list[int] = []
114
+ for rule in rules:
115
+ ctx["rule"] = rule.name
116
+ group_key = render(rule.group, ctx)
117
+ existing = None
118
+ if rule.coalesce:
119
+ existing = self.db.execute(
120
+ "SELECT id FROM runs WHERE rule=? AND group_key=? AND status='queued' ORDER BY id LIMIT 1",
121
+ (rule.name, group_key),
122
+ ).fetchone()
123
+ if existing:
124
+ run_id = existing["id"]
125
+ coalesced.append(run_id)
126
+ else:
127
+ spec = rule.to_spec()
128
+ spec["memory"] = cfg.memory
129
+ spec["require_decision_for"] = cfg.require_decision_for
130
+ cur = self.db.execute(
131
+ "INSERT INTO runs (rule, worker_pattern, group_key, concurrency, spec, status, created_at, parent_run) "
132
+ "VALUES (?,?,?,?,?, 'queued', ?, ?)",
133
+ (rule.name, rule.worker, group_key, rule.concurrency, json.dumps(spec, ensure_ascii=False), now,
134
+ parent_run),
135
+ )
136
+ run_id = cur.lastrowid
137
+ run_ids.append(run_id)
138
+ self.db.execute("INSERT OR IGNORE INTO run_events (run_id, event_id) VALUES (?,?)", (run_id, eid))
139
+ if coalesced and not run_ids:
140
+ self.db.execute("UPDATE events SET status='coalesced', note=? WHERE id=?",
141
+ (f"joined queued run(s) {coalesced}", eid))
142
+ self.cv.notify_all()
143
+ return {
144
+ "event_id": eid,
145
+ "status": "coalesced" if coalesced and not run_ids else "routed",
146
+ "runs": run_ids,
147
+ "coalesced_into": coalesced,
148
+ }
149
+
150
+ def _insert_event(self, e: dict[str, Any], now: float, status: str, note: str = "") -> int:
151
+ cur = self.db.execute(
152
+ "INSERT INTO events (received_at, source, type, subject, summary, link, payload, dedupe_key, status, note) "
153
+ "VALUES (?,?,?,?,?,?,?,?,?,?)",
154
+ (now, e["source"], e["type"], e["subject"], e["summary"], e["link"],
155
+ json.dumps(e["payload"], ensure_ascii=False), e["dedupe_key"], status, note),
156
+ )
157
+ return cur.lastrowid
158
+
159
+ # ------------------------------------------------------------------ claim
160
+
161
+ def claim(self, worker: str, wait: float = 0.0, runners: list[str] | None = None) -> dict[str, Any] | None:
162
+ if not worker:
163
+ raise HubError(400, "worker name is required")
164
+ deadline = time.monotonic() + max(0.0, wait)
165
+ with self.cv:
166
+ while True:
167
+ task = self._try_claim(worker, runners)
168
+ if task is not None:
169
+ return task
170
+ remaining = deadline - time.monotonic()
171
+ if remaining <= 0:
172
+ return None
173
+ self.cv.wait(timeout=min(remaining, 5.0))
174
+
175
+ def _try_claim(self, worker: str, runners: list[str] | None) -> dict[str, Any] | None:
176
+ running = self.db.execute("SELECT rule, group_key FROM runs WHERE status='running'").fetchall()
177
+ busy_groups = {r["group_key"] for r in running}
178
+ per_rule: dict[str, int] = {}
179
+ for r in running:
180
+ per_rule[r["rule"]] = per_rule.get(r["rule"], 0) + 1
181
+ for row in self.db.execute("SELECT * FROM runs WHERE status='queued' ORDER BY id").fetchall():
182
+ if not fnmatch.fnmatchcase(worker, row["worker_pattern"]):
183
+ continue
184
+ if row["group_key"] in busy_groups:
185
+ continue
186
+ if per_rule.get(row["rule"], 0) >= row["concurrency"]:
187
+ continue
188
+ spec = json.loads(row["spec"])
189
+ if runners is not None and spec["runner"] not in runners:
190
+ continue
191
+ now = self.clock()
192
+ events = self._events_for_run(row["id"])
193
+ prompt = build_prompt(row["id"], spec, events, row["parent_run"],
194
+ attempt=row["attempt"], retry_of=row["retry_of"])
195
+ self.db.execute(
196
+ "UPDATE runs SET status='running', claimed_by=?, claimed_at=?, heartbeat_at=?, prompt=? WHERE id=?",
197
+ (worker, now, now, prompt, row["id"]),
198
+ )
199
+ task = {"run_id": row["id"], **spec, "prompt": prompt, "events": events}
200
+ if row["parent_run"]:
201
+ parent = self.db.execute("SELECT session_id FROM runs WHERE id=?", (row["parent_run"],)).fetchone()
202
+ task["resume_session"] = parent["session_id"] if parent else None
203
+ return task
204
+ return None
205
+
206
+ def _events_for_run(self, run_id: int) -> list[dict[str, Any]]:
207
+ rows = self.db.execute(
208
+ "SELECT e.* FROM events e JOIN run_events re ON re.event_id=e.id WHERE re.run_id=? ORDER BY e.id",
209
+ (run_id,),
210
+ ).fetchall()
211
+ out = []
212
+ for r in rows:
213
+ d = store.row_to_dict(r)
214
+ out.append({k: d[k] for k in ("id", "source", "type", "subject", "summary", "link", "payload")})
215
+ return out
216
+
217
+ # ------------------------------------------------------------------ progress
218
+
219
+ def _running(self, run_id: int, worker: str) -> dict[str, Any]:
220
+ row = self.db.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
221
+ if row is None:
222
+ raise HubError(404, f"run {run_id} not found")
223
+ if row["status"] != "running" or row["claimed_by"] != worker:
224
+ raise HubError(409, f"run {run_id} is not running on {worker} (status {row['status']})")
225
+ return row
226
+
227
+ def heartbeat(self, run_id: int, worker: str) -> None:
228
+ with self.lock:
229
+ self._running(run_id, worker)
230
+ self.db.execute("UPDATE runs SET heartbeat_at=? WHERE id=?", (self.clock(), run_id))
231
+
232
+ def finish(self, run_id: int, worker: str, result: dict[str, Any]) -> dict[str, Any]:
233
+ with self.lock:
234
+ self._running(run_id, worker)
235
+ exit_code = int(result.get("exit_code", 1))
236
+ output = str(result.get("output", ""))
237
+ summary = result.get("summary") or extract_summary(output)
238
+ decision_req = result.get("needs_decision") or extract_decision(output)
239
+ if decision_req is not None and not _valid_decision(decision_req):
240
+ decision_req = None
241
+ reported = _normalize_status(result.get("status")) or extract_status(output)
242
+ if decision_req:
243
+ status = "awaiting_decision"
244
+ elif exit_code != 0 or reported == "failed":
245
+ # The agent's own verdict wins over a clean exit: a process can exit 0 without doing the job.
246
+ status = "failed"
247
+ else:
248
+ status = "succeeded"
249
+ self.db.execute(
250
+ "UPDATE runs SET status=?, finished_at=?, exit_code=?, summary=?, session_id=?, output_tail=?, "
251
+ "decision_request=?, cost_usd=?, input_tokens=?, output_tokens=? WHERE id=?",
252
+ (status, self.clock(), exit_code, (summary or "")[:2000], result.get("session_id"),
253
+ output[-8000:], json.dumps(decision_req, ensure_ascii=False) if decision_req else None,
254
+ _num(result.get("cost_usd"), float), _num(result.get("input_tokens"), int),
255
+ _num(result.get("output_tokens"), int), run_id),
256
+ )
257
+ self.cv.notify_all()
258
+ run = self.get_run(run_id)
259
+ self._maybe_notify(run)
260
+ return {"run_id": run_id, "status": status}
261
+
262
+ def decide(self, run_id: int, choice: str, note: str = "", by: str = "human") -> dict[str, Any]:
263
+ with self.lock:
264
+ run = self.get_run(run_id)
265
+ if run is None:
266
+ raise HubError(404, f"run {run_id} not found")
267
+ if run["status"] != "awaiting_decision":
268
+ raise HubError(409, f"run {run_id} is not awaiting a decision (status {run['status']})")
269
+ req = run["decision_request"] or {}
270
+ options = req.get("options") or []
271
+ if options and choice not in options:
272
+ raise HubError(400, f"choice must be one of {options}")
273
+ decision = {"choice": choice, "note": note, "by": by, "at": self.clock()}
274
+ self.db.execute("UPDATE runs SET status='decided', decision=? WHERE id=?",
275
+ (json.dumps(decision, ensure_ascii=False), run_id))
276
+ # A decision is just another event, so rules decide what happens next.
277
+ follow = self.ingest({
278
+ "source": "dispatchbox",
279
+ "type": "decision.made",
280
+ "subject": f"run-{run_id}",
281
+ "summary": f"{req.get('question', '')} -> {choice}",
282
+ "link": f"{self.public_url}/runs/{run_id}" if self.public_url else "",
283
+ "payload": {"run_id": run_id, "rule": run["rule"], "question": req.get("question"),
284
+ "choice": choice, "note": note, "session_id": run["session_id"]},
285
+ "dedupe_key": f"dispatchbox:decision:{run_id}",
286
+ }, parent_run=run_id)
287
+ return {"run_id": run_id, "status": "decided", "follow_up": follow}
288
+
289
+ # ------------------------------------------------------------------ housekeeping
290
+
291
+ def reap(self) -> list[int]:
292
+ """Mark runs whose worker stopped sending heartbeats as lost."""
293
+ now = self.clock()
294
+ lost: list[int] = []
295
+ with self.lock:
296
+ rows = self.db.execute(
297
+ "SELECT id, heartbeat_at, claimed_at, spec, attempt FROM runs WHERE status='running'").fetchall()
298
+ final: list[int] = []
299
+ for r in rows:
300
+ spec = json.loads(r["spec"])
301
+ silent = now - (r["heartbeat_at"] or 0) > self.lost_after
302
+ overdue = now - (r["claimed_at"] or now) > spec.get("timeout", 1800) + self.lost_after
303
+ if not (silent or overdue):
304
+ continue
305
+ reason = "worker stopped reporting" if silent else "exceeded timeout"
306
+ if r["attempt"] <= spec.get("retries", 0):
307
+ cur = self.db.execute(
308
+ "INSERT INTO runs (rule, worker_pattern, group_key, concurrency, spec, status, created_at, "
309
+ "parent_run, attempt, retry_of) SELECT rule, worker_pattern, group_key, concurrency, spec, "
310
+ "'queued', ?, parent_run, attempt + 1, id FROM runs WHERE id=?",
311
+ (now, r["id"]),
312
+ )
313
+ self.db.execute("INSERT INTO run_events (run_id, event_id) SELECT ?, event_id FROM run_events "
314
+ "WHERE run_id=?", (cur.lastrowid, r["id"]))
315
+ reason += f", retrying as run #{cur.lastrowid}"
316
+ else:
317
+ final.append(r["id"])
318
+ self.db.execute("UPDATE runs SET status='lost', finished_at=?, summary=? WHERE id=?",
319
+ (now, reason, r["id"]))
320
+ lost.append(r["id"])
321
+ if lost:
322
+ self.cv.notify_all()
323
+ # Only tell the owner once nothing will retry.
324
+ for rid in final:
325
+ self._maybe_notify(self.get_run(rid))
326
+ return lost
327
+
328
+ def tick_cron(self, now: datetime | None = None) -> list[dict[str, Any]]:
329
+ now = (now or datetime.now()).replace(second=0, microsecond=0)
330
+ stamp = now.strftime("%Y-%m-%dT%H:%M")
331
+ if stamp == self._last_cron_minute:
332
+ return []
333
+ self._last_cron_minute = stamp
334
+ self.reload_if_changed()
335
+ results = []
336
+ for sched in self.config.schedules:
337
+ if sched.cron.matches(now):
338
+ ev = {**sched.event, "dedupe_key": f"cron:{sched.name}:{stamp}"}
339
+ ev.setdefault("summary", f"scheduled {sched.name} at {stamp}")
340
+ results.append(self.ingest(ev))
341
+ return results
342
+
343
+ # ------------------------------------------------------------------ notify
344
+
345
+ def _maybe_notify(self, run: dict[str, Any] | None) -> None:
346
+ if not run:
347
+ return
348
+ mode = run["spec"].get("notify", "on_decision")
349
+ status = run["status"]
350
+ wanted = (
351
+ mode == "always"
352
+ or (mode in {"on_decision", "on_failure"} and status == "awaiting_decision")
353
+ or (mode == "on_failure" and status in {"failed", "lost"})
354
+ )
355
+ link = self.run_link(run["id"])
356
+ if wanted:
357
+ try:
358
+ self.notifier(run, link)
359
+ except Exception as e: # notifications must never break the hub
360
+ log.error("notify failed for run %s: %s", run["id"], e)
361
+ # Chat adapters do network calls, so they run off the request thread.
362
+ for adapter in self.adapters.values():
363
+ if wanted:
364
+ self.background(adapter.notify, run, link)
365
+ self.background(adapter.run_finished, run, link)
366
+
367
+ def run_link(self, run_id: int) -> str:
368
+ return f"{self.public_url}/runs/{run_id}" if self.public_url else f"/runs/{run_id}"
369
+
370
+ def _default_notify(self, run: dict[str, Any], link: str) -> None:
371
+ text = format_notification(run, link)
372
+ url = os.environ.get("DISPATCHBOX_NOTIFY_URL")
373
+ if not url:
374
+ log.warning("NOTIFY %s", text.replace("\n", " | "))
375
+ return
376
+ if "open.feishu.cn" in url or "open.larksuite.com" in url:
377
+ body = {"msg_type": "text", "content": {"text": text}}
378
+ elif "hooks.slack.com" in url:
379
+ body = {"text": text}
380
+ else:
381
+ body = {"text": text, "run": run}
382
+ req = urllib.request.Request(url, data=json.dumps(body, ensure_ascii=False, default=str).encode(),
383
+ headers={"Content-Type": "application/json"})
384
+ urllib.request.urlopen(req, timeout=10, context=ssl_context()).read()
385
+
386
+ # ------------------------------------------------------------------ queries
387
+
388
+ def get_run(self, run_id: int) -> dict[str, Any] | None:
389
+ with self.lock:
390
+ run = store.row_to_dict(self.db.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone())
391
+ if run:
392
+ run["events"] = self._events_for_run(run_id)
393
+ return run
394
+
395
+ def list_runs(self, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]:
396
+ with self.lock:
397
+ if status:
398
+ rows = self.db.execute("SELECT * FROM runs WHERE status=? ORDER BY id DESC LIMIT ?", (status, limit))
399
+ else:
400
+ rows = self.db.execute("SELECT * FROM runs ORDER BY id DESC LIMIT ?", (limit,))
401
+ return [store.row_to_dict(r) for r in rows.fetchall()]
402
+
403
+ def list_events(self, limit: int = 100) -> list[dict[str, Any]]:
404
+ with self.lock:
405
+ rows = self.db.execute("SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
406
+ return [store.row_to_dict(r) for r in rows]
407
+
408
+ def stats(self) -> dict[str, int]:
409
+ with self.lock:
410
+ rows = self.db.execute("SELECT status, COUNT(*) n FROM runs GROUP BY status").fetchall()
411
+ return {r["status"]: r["n"] for r in rows}
412
+
413
+
414
+ def _background(fn: Callable[..., Any], *args: Any) -> None:
415
+ def run() -> None:
416
+ try:
417
+ fn(*args)
418
+ except Exception:
419
+ log.exception("background task %s failed", getattr(fn, "__qualname__", fn))
420
+ threading.Thread(target=run, daemon=True).start()
421
+
422
+
423
+ # ---------------------------------------------------------------------- prompt & result contract
424
+
425
+ def build_prompt(run_id: int, spec: dict[str, Any], events: list[dict[str, Any]], parent_run: int | None,
426
+ attempt: int = 1, retry_of: int | None = None) -> str:
427
+ latest = events[-1] if events else {}
428
+ ctx = {**latest, "rule": spec["rule"], "event_count": len(events)}
429
+ parts = [render(spec.get("prompt") or "", ctx).strip()]
430
+ if spec.get("skill"):
431
+ parts.append(f"Use the `{spec['skill']}` skill for this task.")
432
+
433
+ lines = [f"## Trigger", f"Started automatically by dispatchbox: run #{run_id}, rule \"{spec['rule']}\"."]
434
+ if parent_run:
435
+ lines.append(f"This continues run #{parent_run} after a human decision (see the decision.made event).")
436
+ if retry_of:
437
+ lines.append(f"This is attempt {attempt}. The previous attempt (run #{retry_of}) stopped reporting midway "
438
+ "and may have done part of the work. Check the current state before repeating any step.")
439
+ if len(events) > 1:
440
+ lines.append(f"{len(events)} related events were merged into this run; handle them together.")
441
+ for e in events:
442
+ line = f"- [{e['source']}/{e['type']}] {e['subject']}: {e['summary']}".rstrip(": ")
443
+ if e.get("link"):
444
+ line += f" ({e['link']})"
445
+ lines.append(line)
446
+ payloads = [{"event": e["id"], "payload": e["payload"]} for e in events if e.get("payload")]
447
+ if payloads:
448
+ blob = json.dumps(payloads, ensure_ascii=False, indent=1)
449
+ if len(blob) > MAX_PAYLOAD_IN_PROMPT:
450
+ blob = blob[:MAX_PAYLOAD_IN_PROMPT] + "\n... (truncated)"
451
+ lines += ["", "Event payloads:", "```json", blob, "```"]
452
+ parts.append("\n".join(lines))
453
+
454
+ if spec.get("memory"):
455
+ files = ", ".join(f"`{m}`" for m in spec["memory"])
456
+ parts.append(
457
+ "## Standing decisions\n"
458
+ f"Before asking anything, read {files} if they exist. They record what the owner already decided "
459
+ "or refused. Follow them and do not ask again about anything they settle."
460
+ )
461
+
462
+ contract = ["## When to ask a human"]
463
+ if spec.get("require_decision_for"):
464
+ contract.append("Stop and ask only before these kinds of actions: "
465
+ + ", ".join(spec["require_decision_for"]) + ". Do everything else yourself.")
466
+ else:
467
+ contract.append("Only ask when you genuinely cannot proceed safely. Do everything else yourself.")
468
+ contract += [
469
+ "To ask, do not take the action. End your final message with exactly one line:",
470
+ f'{DECISION_MARK} {{"question": "<one sentence>", "options": ["<option A>", "<option B>"], "context": "<what you found>"}}',
471
+ "",
472
+ "Always finish with a summary line. Unless you are asking, put a status line right before it:",
473
+ "failed if you could not do what was asked, for any reason, including a denied permission or a",
474
+ "missing tool; succeeded only if the task is actually done. Asking is not a failure, so when you",
475
+ "ask, leave the status line out.",
476
+ f"{STATUS_MARK} succeeded | failed",
477
+ f"{SUMMARY_MARK} <one sentence>",
478
+ ]
479
+ parts.append("\n".join(contract))
480
+ return "\n\n".join(p for p in parts if p)
481
+
482
+
483
+ def extract_summary(output: str) -> str:
484
+ for line in reversed(output.splitlines()):
485
+ line = line.strip()
486
+ if line.startswith(SUMMARY_MARK):
487
+ return line[len(SUMMARY_MARK):].strip()
488
+ tail = [l.strip() for l in output.strip().splitlines()
489
+ if l.strip() and not l.strip().startswith((DECISION_MARK, STATUS_MARK))]
490
+ return tail[-1][:500] if tail else ""
491
+
492
+
493
+ def _normalize_status(value: Any) -> str | None:
494
+ if not isinstance(value, str):
495
+ return None
496
+ v = value.strip().strip("`*").lower()
497
+ if v in {"succeeded", "success", "done", "ok", "completed"}:
498
+ return "succeeded"
499
+ if v in {"failed", "failure", "error", "blocked", "incomplete"}:
500
+ return "failed"
501
+ return None
502
+
503
+
504
+ def extract_status(output: str) -> str | None:
505
+ """Read the agent's own verdict from its last DISPATCHBOX_STATUS line, if any."""
506
+ for line in reversed(output.splitlines()):
507
+ line = line.strip()
508
+ if line.startswith(STATUS_MARK):
509
+ return _normalize_status(line[len(STATUS_MARK):])
510
+ return None
511
+
512
+
513
+ def _num(value: Any, kind: type) -> Any:
514
+ try:
515
+ return kind(value) if value is not None else None
516
+ except (TypeError, ValueError):
517
+ return None
518
+
519
+
520
+ def extract_decision(output: str) -> dict[str, Any] | None:
521
+ for line in reversed(output.splitlines()):
522
+ line = line.strip()
523
+ if line.startswith(DECISION_MARK):
524
+ try:
525
+ return json.loads(line[len(DECISION_MARK):].strip())
526
+ except json.JSONDecodeError:
527
+ return {"question": line[len(DECISION_MARK):].strip(), "options": []}
528
+ return None
529
+
530
+
531
+ def _valid_decision(d: Any) -> bool:
532
+ return isinstance(d, dict) and isinstance(d.get("question"), str) and d["question"].strip() != "" \
533
+ and isinstance(d.get("options", []), list)
534
+
535
+
536
+ def format_notification(run: dict[str, Any], link: str) -> str:
537
+ status = run["status"]
538
+ head = f"[dispatchbox] run #{run['id']} {run['rule']}: {status}"
539
+ lines = [head]
540
+ if status == "awaiting_decision" and run.get("decision_request"):
541
+ req = run["decision_request"]
542
+ lines.append(f"Question: {req.get('question')}")
543
+ if req.get("options"):
544
+ lines.append("Options: " + " / ".join(req["options"]))
545
+ if req.get("context"):
546
+ lines.append(f"Context: {req['context']}")
547
+ elif run.get("summary"):
548
+ lines.append(run["summary"])
549
+ lines.append(f"Open: {link}")
550
+ return "\n".join(lines)
dispatchbox/net.py ADDED
@@ -0,0 +1,33 @@
1
+ """TLS settings shared by every outgoing HTTPS call (Slack, Feishu, webhooks, worker to hub)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import ssl
8
+
9
+ log = logging.getLogger("dispatchbox.net")
10
+
11
+
12
+ @functools.lru_cache(maxsize=1)
13
+ def ssl_context() -> ssl.SSLContext:
14
+ """The system trust store, plus certifi's bundle when the system one is empty.
15
+
16
+ python.org builds of Python on macOS ship without CA certificates until "Install Certificates.command"
17
+ is run, and then every HTTPS call fails with CERTIFICATE_VERIFY_FAILED."""
18
+ ctx = ssl.create_default_context()
19
+ if ctx.cert_store_stats()["x509_ca"]:
20
+ return ctx
21
+ try:
22
+ import certifi
23
+ except ImportError:
24
+ log.warning("this Python has no CA certificates, so HTTPS calls will fail. Fix: pip install certifi, "
25
+ "or on macOS run \"/Applications/Python 3.x/Install Certificates.command\"")
26
+ return ctx
27
+ ctx.load_verify_locations(certifi.where())
28
+ return ctx
29
+
30
+
31
+ def context_for(url: str) -> ssl.SSLContext | None:
32
+ """TLS settings for url, or None for plain HTTP, so a local hub never triggers the certificate warning."""
33
+ return ssl_context() if url.lower().startswith("https:") else None