Compare commits
14 Commits
fix/write-
...
docs/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db3bf6ddd | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 | |||
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed |
21
docs/changelog-2026-05-hooks-jobs.md
Normal file
21
docs/changelog-2026-05-hooks-jobs.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# שינויים — legal-ai backend (2026-05-17)
|
||||||
|
|
||||||
|
## הוספת webhook emitter לסטטוס תיק
|
||||||
|
|
||||||
|
### `web/paperclip_api.py`
|
||||||
|
- נוספה `emit_case_status_webhook()` — fire-and-forget helper שמדווח ל-Paperclip plugin על שינוי סטטוס
|
||||||
|
- שימוש ב-`datetime.now(timezone.utc)` במקום `datetime.utcnow()` המיושן (תואם Python 3.12+)
|
||||||
|
|
||||||
|
### `web/app.py`
|
||||||
|
- `PUT /api/cases/{case_number}` — שולח webhook ב-BackgroundTask כשהסטטוס משתנה
|
||||||
|
- שומר `old_status` לפני העדכון → משווה עם `new_status` → מפעיל webhook רק אם שונה
|
||||||
|
- `GET /api/cases/stale?days=3` — מחזיר תיקים שלא עודכנו N+ ימים (לשימוש `stale-case-reminder` job)
|
||||||
|
- `GET /api/chair-feedback/weekly-summary?days=7` — מסכם פידבק יו"ר לשבוע אחרון (לשימוש `weekly-feedback-analysis` job)
|
||||||
|
|
||||||
|
## שינויים ב-sync script
|
||||||
|
|
||||||
|
### `scripts/sync_agents_across_companies.py`
|
||||||
|
- `--check-instructions`: מדפיס טבלה עם סטטוס הוראות לכל 14 הסוכנים (✅ מעודכן / DRIFT / ⚠ NOT SET)
|
||||||
|
- pre-flight validation לפני `--apply`: אם קובץ הוראות חסר → מבטל בעדינות
|
||||||
|
- מעקב `claude_md_mtime` + `claude_md_last_synced` ב-metadata של הסוכן
|
||||||
|
- alias: `check-agents` ב-`.bashrc`
|
||||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
|||||||
|
|
||||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||||
"universal": [
|
"universal": [
|
||||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
|||||||
if "runtime_config" in diff:
|
if "runtime_config" in diff:
|
||||||
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
||||||
|
|
||||||
|
# Stamp claude_md_mtime + last_synced into metadata
|
||||||
|
mtime = diff.get("_claude_md_mtime")
|
||||||
|
if mtime:
|
||||||
|
current_meta = dict(patch_body.get("metadata") or {})
|
||||||
|
current_meta["claude_md_mtime"] = mtime
|
||||||
|
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
patch_body["metadata"] = current_meta
|
||||||
|
|
||||||
if patch_body:
|
if patch_body:
|
||||||
status, data = await call_patch(mirror_id, patch_body)
|
status, data = await call_patch(mirror_id, patch_body)
|
||||||
if status >= 400:
|
if status >= 400:
|
||||||
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_claude_md_mtime(adapter_config: dict) -> str | None:
|
||||||
|
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
|
||||||
|
path = adapter_config.get("instructionsFilePath", "")
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
return str(int(os.path.getmtime(path)))
|
||||||
|
|
||||||
|
|
||||||
|
async def check_instructions(agents: list[dict]) -> bool:
|
||||||
|
"""Print a report of all agents' instruction files. Returns True if all OK."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
|
||||||
|
print("-" * 115)
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
name = (agent.get("name") or agent.get("id") or "?")[:29]
|
||||||
|
|
||||||
|
try:
|
||||||
|
adapter_cfg = agent.get("adapter_config") or {}
|
||||||
|
if isinstance(adapter_cfg, str):
|
||||||
|
adapter_cfg = json.loads(adapter_cfg)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = adapter_cfg.get("instructionsFilePath", "")
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
stat = os.stat(file_path)
|
||||||
|
size_kb = stat.st_size // 1024
|
||||||
|
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# Check for drift vs DB metadata
|
||||||
|
try:
|
||||||
|
metadata = agent.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
metadata = {}
|
||||||
|
db_mtime = metadata.get("claude_md_mtime", "")
|
||||||
|
actual_mtime = str(int(stat.st_mtime))
|
||||||
|
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
|
||||||
|
|
||||||
|
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
g = p.add_mutually_exclusive_group(required=True)
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
||||||
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||||
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
||||||
|
g.add_argument("--check-instructions", action="store_true",
|
||||||
|
help="Scan all agents' instructionsFilePath and report missing/outdated files")
|
||||||
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
@@ -295,6 +364,11 @@ async def main() -> None:
|
|||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
if args.check_instructions:
|
||||||
|
all_agents = master_agents + mirror_agents
|
||||||
|
all_ok = await check_instructions(all_agents)
|
||||||
|
sys.exit(0 if all_ok else 1)
|
||||||
|
|
||||||
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||||
|
|
||||||
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
||||||
@@ -332,6 +406,14 @@ async def main() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# APPLY
|
# APPLY
|
||||||
|
# Pre-flight: abort if any master agent is missing its instructions file
|
||||||
|
print("🔍 Pre-flight: checking instruction files...")
|
||||||
|
all_ok = await check_instructions(master_agents)
|
||||||
|
if not all_ok:
|
||||||
|
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Pre-flight passed.\n")
|
||||||
|
|
||||||
print(f"\n=== Backup ===")
|
print(f"\n=== Backup ===")
|
||||||
backup_path = backup_agents_table()
|
backup_path = backup_agents_table()
|
||||||
print(f" ✓ {backup_path}")
|
print(f" ✓ {backup_path}")
|
||||||
@@ -340,6 +422,11 @@ async def main() -> None:
|
|||||||
all_errors: list[str] = []
|
all_errors: list[str] = []
|
||||||
for master, mirror, diff in plan:
|
for master, mirror, diff in plan:
|
||||||
print(f"\n → {master['name']} ({mirror['id']})")
|
print(f"\n → {master['name']} ({mirror['id']})")
|
||||||
|
# Inject mtime into diff so apply_diff can stamp metadata
|
||||||
|
master_ac = master.get("adapter_config") or {}
|
||||||
|
mtime = get_claude_md_mtime(master_ac)
|
||||||
|
if mtime:
|
||||||
|
diff["_claude_md_mtime"] = mtime
|
||||||
errors = await apply_diff(mirror["id"], master["name"], diff)
|
errors = await apply_diff(mirror["id"], master["name"], diff)
|
||||||
if errors:
|
if errors:
|
||||||
for e in errors:
|
for e in errors:
|
||||||
|
|||||||
108
web/app.py
108
web/app.py
@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
|||||||
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
|
|||||||
normalize_for_compare,
|
normalize_for_compare,
|
||||||
)
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
from web.paperclip_api import pc_request
|
from web.paperclip_api import emit_case_status_webhook, pc_request
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
COMPANIES as PAPERCLIP_COMPANIES,
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
@@ -1135,6 +1135,36 @@ async def list_cases(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/stale")
|
||||||
|
async def api_stale_cases(days: int = 3):
|
||||||
|
"""Return cases that haven't been updated in N days and are not in 'final' or 'new' status."""
|
||||||
|
if days <= 0:
|
||||||
|
return {"cases": [], "total": 0}
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT case_number, title, status,
|
||||||
|
EXTRACT(DAY FROM (now() - updated_at))::int AS days_stale
|
||||||
|
FROM cases
|
||||||
|
WHERE status NOT IN ('final', 'new')
|
||||||
|
AND updated_at < now() - make_interval(days => $1)
|
||||||
|
ORDER BY updated_at ASC -- oldest stale first (longest overdue = highest priority)
|
||||||
|
""",
|
||||||
|
days,
|
||||||
|
)
|
||||||
|
cases = [
|
||||||
|
{
|
||||||
|
"case_number": r["case_number"],
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"days_stale": r["days_stale"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"cases": cases, "total": len(cases)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/archive")
|
@app.post("/api/cases/{case_number}/archive")
|
||||||
async def api_archive_case(case_number: str):
|
async def api_archive_case(case_number: str):
|
||||||
"""Move a case to the archive. Also archives the matching Paperclip project."""
|
"""Move a case to the archive. Also archives the matching Paperclip project."""
|
||||||
@@ -1337,8 +1367,12 @@ async def api_case_get(case_number: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/api/cases/{case_number}")
|
@app.put("/api/cases/{case_number}")
|
||||||
async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
|
||||||
"""Update case details."""
|
"""Update case details."""
|
||||||
|
# Capture old status before the update so we can detect changes.
|
||||||
|
existing = await db.get_case_by_number(case_number)
|
||||||
|
old_status = (existing or {}).get("status", "")
|
||||||
|
|
||||||
result = await cases_tools.case_update(
|
result = await cases_tools.case_update(
|
||||||
case_number=case_number,
|
case_number=case_number,
|
||||||
status=req.status,
|
status=req.status,
|
||||||
@@ -1351,10 +1385,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
|||||||
expected_outcome=req.expected_outcome,
|
expected_outcome=req.expected_outcome,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return json.loads(result)
|
parsed = json.loads(result)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
raise HTTPException(404, result)
|
raise HTTPException(404, result)
|
||||||
|
|
||||||
|
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
|
||||||
|
new_status = req.status
|
||||||
|
if new_status and old_status != new_status:
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
emit_case_status_webhook,
|
||||||
|
case_number=case_number,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status,
|
||||||
|
company_id=company_id, # None is safe — plugin handles unknown company gracefully
|
||||||
|
)
|
||||||
|
logger.debug("webhook scheduled: case %s %s → %s", case_number, old_status, new_status)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/cases")
|
@app.delete("/api/cases")
|
||||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||||
@@ -3057,8 +3111,16 @@ async def api_get_methodology(category: str):
|
|||||||
items = {}
|
items = {}
|
||||||
for key, default_val in defaults.items():
|
for key, default_val in defaults.items():
|
||||||
if key in overrides:
|
if key in overrides:
|
||||||
|
raw = overrides[key]["rule_value"]
|
||||||
|
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
|
||||||
|
# Parse it back to a Python object so the frontend receives the correct type.
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
items[key] = {
|
items[key] = {
|
||||||
"value": overrides[key]["rule_value"],
|
"value": raw,
|
||||||
"is_override": True,
|
"is_override": True,
|
||||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||||
}
|
}
|
||||||
@@ -3095,10 +3157,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
|
|||||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||||
|
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
|
# json.dumps → text, then PostgreSQL casts text→jsonb.
|
||||||
|
# Passing a Python list directly causes "expected str, got list" in asyncpg;
|
||||||
|
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
|
||||||
|
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3979,6 +4045,34 @@ async def api_resolve_feedback(feedback_id: str, body: dict):
|
|||||||
return {"status": "resolved"}
|
return {"status": "resolved"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/chair-feedback/weekly-summary")
|
||||||
|
async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
|
||||||
|
"""Return chair feedback from the last N days as a text summary for the CEO agent."""
|
||||||
|
if days <= 0:
|
||||||
|
return {"summary": "", "entry_count": 0}
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT cf.feedback_text, c.case_number, c.title
|
||||||
|
FROM chair_feedback cf
|
||||||
|
LEFT JOIN cases c ON c.id = cf.case_id
|
||||||
|
WHERE cf.created_at >= now() - make_interval(days => $1)
|
||||||
|
ORDER BY cf.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
""",
|
||||||
|
days,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return {"summary": "", "entry_count": 0}
|
||||||
|
lines = [
|
||||||
|
f"- תיק {r['case_number'] or '—'} ({r['title'] or '—'}): {r['feedback_text']}"
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"summary": "\n".join(lines), "entry_count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
# ── Background Processing ─────────────────────────────────────────
|
# ── Background Processing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -81,3 +82,35 @@ async def pc_request(
|
|||||||
if raise_on_error:
|
if raise_on_error:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_case_status_webhook(
|
||||||
|
case_number: str,
|
||||||
|
old_status: str,
|
||||||
|
new_status: str,
|
||||||
|
company_id: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Notify the Paperclip plugin that a case status changed.
|
||||||
|
|
||||||
|
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await pc_request(
|
||||||
|
"POST",
|
||||||
|
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
||||||
|
json={
|
||||||
|
"caseNumber": case_number,
|
||||||
|
"oldStatus": old_status,
|
||||||
|
"newStatus": new_status,
|
||||||
|
"companyId": company_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
run_id=run_id,
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"emit_case_status_webhook failed for case %s (%s → %s): %s",
|
||||||
|
case_number, old_status, new_status, exc,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user