Fix case repo sync + auto-create Gitea repos + add sync indicator
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m30s

- auto-sync-cases.sh: fix broken directory scan (was looking for
  status subdirs that don't exist), fix env var word-splitting bug,
  add safe.directory handling and error logging
- cases.py: auto-create Gitea repo on case_create, fix
  documents/original → documents/originals naming mismatch
- app.py: add GET /api/cases/{case_number}/git-status endpoint
- web-ui: add SyncIndicator component in case header showing
  sync status (synced/pending/no remote) with last commit time
- pyproject.toml: add httpx dependency
- CLAUDE.md: update Paperclip wakeup API docs
- settings page: switch tag input from Select to free-text with datalist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 15:28:16 +00:00
parent 7509d7e580
commit 82ba4663ba
9 changed files with 316 additions and 45 deletions

View File

@@ -106,10 +106,15 @@
## Paperclip — כללי אינטגרציה קריטיים ## Paperclip — כללי אינטגרציה קריטיים
### Wakeup API — תמיד דרך API, לעולם לא דרך DB ### Wakeup API — תמיד דרך API, לעולם לא דרך DB
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` עם `{"source": "...", "reason": "..."}` - **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק את הרשומה בטבלה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם** - **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
- **הסיבה**: ה-dispatcher של Paperclip מעבד `heartbeat_runs`, לא `agent_wakeup_requests`. רק ה-API יוצר את שניהם אטומית - **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
- **Board API Key**: שמור ב-DB (`board_api_keys`), מאפשר גישה ל-API מ-CLI - דוגמה נכונה:
```json
{"source": "automation", "triggerDetail": "system", "reason": "...",
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
```
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
### ניתוב comments דרך CEO ### ניתוב comments דרך CEO
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()` - כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`

View File

@@ -19,6 +19,7 @@ dependencies = [
"google-cloud-vision>=3.7.0", "google-cloud-vision>=3.7.0",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"httpx>=0.27.0",
] ]
[build-system] [build-system]

View File

@@ -3,14 +3,107 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
import httpx
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import audit, db, practice_area as pa from legal_mcp.services import audit, db, practice_area as pa
logger = logging.getLogger(__name__)
GITEA_ORG = "cases"
def _gitea_host() -> str:
return os.environ.get("GITEA_HOST", "https://gitea.nautilus.marcusgroup.org")
def _gitea_token() -> str:
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> bool:
"""Create Gitea repo and configure git remote. Best-effort — returns False on failure."""
token = _gitea_token()
if not token:
logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number)
return False
try:
async with httpx.AsyncClient(verify=False, timeout=30) as client:
resp = await client.post(
f"{_gitea_host()}/api/v1/orgs/{GITEA_ORG}/repos",
headers={"Authorization": f"token {token}"},
json={
"name": case_number,
"description": f"ערר {case_number}{title}"[:255],
"private": True,
"auto_init": False,
},
)
if resp.status_code == 409:
resp2 = await client.get(
f"{_gitea_host()}/api/v1/repos/{GITEA_ORG}/{case_number}",
headers={"Authorization": f"token {token}"},
)
resp2.raise_for_status()
repo = resp2.json()
else:
resp.raise_for_status()
repo = resp.json()
clone_url = repo.get("clone_url", "")
if not clone_url:
return False
auth_url = clone_url.replace("https://", f"https://chaim:{token}@")
git_env = {
"GIT_AUTHOR_NAME": "Ezer Mishpati",
"GIT_AUTHOR_EMAIL": "legal@local",
"GIT_COMMITTER_NAME": "Ezer Mishpati",
"GIT_COMMITTER_EMAIL": "legal@local",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
# Add or update remote
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=case_dir, capture_output=True, text=True,
)
if result.returncode == 0:
subprocess.run(
["git", "remote", "set-url", "origin", auth_url],
cwd=case_dir, capture_output=True, env=git_env,
)
else:
subprocess.run(
["git", "remote", "add", "origin", auth_url],
cwd=case_dir, capture_output=True, env=git_env,
)
# Push
push = subprocess.run(
["git", "push", "-u", "origin", "HEAD"],
cwd=case_dir, capture_output=True, text=True, env=git_env,
)
if push.returncode != 0:
logger.warning("Gitea push failed for %s: %s", case_number, push.stderr)
return False
logger.info("Gitea repo created and pushed for %s", case_number)
return True
except Exception as exc:
logger.warning("Gitea setup failed for %s: %s", case_number, exc)
return False
async def case_create( async def case_create(
case_number: str, case_number: str,
@@ -92,7 +185,7 @@ async def case_create(
case_dir.mkdir(parents=True, exist_ok=True) case_dir.mkdir(parents=True, exist_ok=True)
docs_dir = case_dir / "documents" docs_dir = case_dir / "documents"
docs_dir.mkdir(exist_ok=True) docs_dir.mkdir(exist_ok=True)
(docs_dir / "original").mkdir(exist_ok=True) (docs_dir / "originals").mkdir(exist_ok=True)
(docs_dir / "extracted").mkdir(exist_ok=True) (docs_dir / "extracted").mkdir(exist_ok=True)
(docs_dir / "proofread").mkdir(exist_ok=True) (docs_dir / "proofread").mkdir(exist_ok=True)
(docs_dir / "backup").mkdir(exist_ok=True) (docs_dir / "backup").mkdir(exist_ok=True)
@@ -121,6 +214,12 @@ async def case_create(
except Exception: except Exception:
pass # git not available — non-critical pass # git not available — non-critical
# Create Gitea repo and configure remote (best-effort)
try:
await _setup_gitea_remote(case_number, title, case_dir)
except Exception:
pass # Gitea not available — non-critical
return json.dumps(case, default=str, ensure_ascii=False, indent=2) return json.dumps(case, default=str, ensure_ascii=False, indent=2)

View File

@@ -4,13 +4,22 @@
CASES_DIR="/home/chaim/legal-ai/data/cases" CASES_DIR="/home/chaim/legal-ai/data/cases"
LOG="/home/chaim/legal-ai/data/.auto-sync.log" LOG="/home/chaim/legal-ai/data/.auto-sync.log"
GIT_ENV="GIT_AUTHOR_NAME=Ezer Mishpati GIT_AUTHOR_EMAIL=legal@local GIT_COMMITTER_NAME=Ezer Mishpati GIT_COMMITTER_EMAIL=legal@local GIT_TERMINAL_PROMPT=0"
for status_dir in "$CASES_DIR"/new "$CASES_DIR"/in-progress "$CASES_DIR"/completed; do export GIT_AUTHOR_NAME="Ezer Mishpati"
[ -d "$status_dir" ] || continue export GIT_AUTHOR_EMAIL="legal@local"
for case_dir in "$status_dir"/*/; do export GIT_COMMITTER_NAME="Ezer Mishpati"
export GIT_COMMITTER_EMAIL="legal@local"
export GIT_TERMINAL_PROMPT=0
for case_dir in "$CASES_DIR"/*/; do
[ -d "$case_dir/.git" ] || continue [ -d "$case_dir/.git" ] || continue
case_name=$(basename "$case_dir")
# Ensure safe.directory is set for this repo
git config --global --get-all safe.directory | grep -qF "$case_dir" \
|| git config --global --add safe.directory "$case_dir"
cd "$case_dir" || continue cd "$case_dir" || continue
# Check for any changes (modified, new, deleted) # Check for any changes (modified, new, deleted)
@@ -20,18 +29,25 @@ for status_dir in "$CASES_DIR"/new "$CASES_DIR"/in-progress "$CASES_DIR"/complet
# Stage all changes # Stage all changes
git add -A 2>/dev/null git add -A 2>/dev/null
# Build commit message from changed files # Count changed files
changed_files=$(git diff --cached --name-only 2>/dev/null | head -5)
count=$(git diff --cached --name-only 2>/dev/null | wc -l) count=$(git diff --cached --name-only 2>/dev/null | wc -l)
case_name=$(basename "$case_dir") [ "$count" -eq 0 ] && continue
msg="סנכרון אוטומטי — ${count} קבצים שונו" msg="סנכרון אוטומטי — ${count} קבצים שונו"
# Commit # Commit
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null if git commit -m "$msg" --quiet 2>/dev/null; then
if [ $? -eq 0 ]; then # Push only if remote exists
# Push (non-blocking, ignore errors) if git remote get-url origin >/dev/null 2>&1; then
git push origin main --quiet 2>/dev/null if git push origin HEAD --quiet 2>/dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG" echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced + pushed" >> "$LOG"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed, push FAILED" >> "$LOG"
fi
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed (no remote)" >> "$LOG"
fi
else
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG"
fi fi
done
done done

View File

@@ -25,6 +25,8 @@ import {
import { APPEAL_SUBTYPES } from "@/lib/practice-area"; import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner"; import { toast } from "sonner";
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
export default function SettingsPage() { export default function SettingsPage() {
const { data: mappings, isPending: loadingMappings } = useTagMappings(); const { data: mappings, isPending: loadingMappings } = useTagMappings();
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies(); const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
@@ -35,9 +37,9 @@ export default function SettingsPage() {
const [tagLabel, setTagLabel] = useState(""); const [tagLabel, setTagLabel] = useState("");
const [companyId, setCompanyId] = useState(""); const [companyId, setCompanyId] = useState("");
function handleTagChange(value: string) { function handleTagInput(value: string) {
setTag(value); setTag(value);
const match = APPEAL_SUBTYPES.find((s) => s.value === value); const match = TAG_SUGGESTIONS.find((s) => s.value === value);
if (match) setTagLabel(match.label); if (match) setTagLabel(match.label);
} }
@@ -137,22 +139,22 @@ export default function SettingsPage() {
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule"> <div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
<div className="flex flex-col gap-1.5 min-w-[180px]"> <div className="flex flex-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted"> <label className="text-[0.72rem] text-ink-muted">
תגית (appeal subtype) תגית
</label> </label>
<Select value={tag} onValueChange={handleTagChange}> <Input
<SelectTrigger className="w-[200px]"> list="tag-suggestions"
<SelectValue placeholder="בחר סוג ערר" /> value={tag}
</SelectTrigger> onChange={(e) => handleTagInput(e.target.value)}
<SelectContent> placeholder="סוג ערר או תגית חופשית"
{APPEAL_SUBTYPES.filter((s) => s.value !== "unknown").map( className="w-[220px]"
(s) => ( />
<SelectItem key={s.value} value={s.value}> <datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label} {s.label}
</SelectItem> </option>
), ))}
)} </datalist>
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-1.5 min-w-[140px]"> <div className="flex flex-col gap-1.5 min-w-[140px]">

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge"; import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import { import {
PRACTICE_AREA_LABELS, PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS, APPEAL_SUBTYPE_LABELS,
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
עודכן עודכן
</dt> </dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd> <dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
סנכרון
</dt>
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
</dl> </dl>
</div> </div>
</CardContent> </CardContent>

View File

@@ -0,0 +1,62 @@
"use client";
import { useGitStatus } from "@/lib/api/cases";
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "עכשיו";
if (mins < 60) return `לפני ${mins} דק׳`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `לפני ${hours} שע׳`;
const days = Math.floor(hours / 24);
return `לפני ${days} ימים`;
}
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
const { data, isLoading } = useGitStatus(caseNumber);
if (isLoading || !data) return null;
if (data.error === "no_repo") {
return (
<span className="inline-flex items-center gap-1 text-[0.7rem] text-ink-muted/60" title="אין ריפו מקומי">
<CloudOff className="w-3 h-3" />
</span>
);
}
const synced = data.synced;
const pending = data.dirty_files + data.commits_ahead;
let Icon = synced ? CheckCircle2 : pending > 0 ? Clock : AlertCircle;
let color = synced
? "text-success"
: pending > 0
? "text-warn"
: "text-ink-muted";
let label = synced
? "מסונכרן"
: pending > 0
? `${pending} שינויים ממתינים`
: "לא מחובר";
if (!data.has_remote) {
Icon = CloudOff;
color = "text-ink-muted/60";
label = "אין remote";
}
const time = data.last_commit_time ? formatRelative(data.last_commit_time) : null;
const tooltip = [label, time ? `commit אחרון: ${time}` : null, data.last_commit_msg]
.filter(Boolean)
.join("\n");
return (
<span className={`inline-flex items-center gap-1 text-[0.7rem] ${color}`} title={tooltip}>
<Icon className="w-3 h-3" />
<span className="hidden sm:inline">{time ?? label}</span>
</span>
);
}

View File

@@ -145,6 +145,28 @@ export function useUpdateCase(caseNumber: string | undefined) {
}); });
} }
export type GitSyncStatus = {
synced: boolean;
has_remote: boolean;
remote_url?: string | null;
dirty_files: number;
commits_ahead: number;
last_commit_time?: string | null;
last_commit_msg?: string | null;
error?: string;
};
export function useGitStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const,
queryFn: ({ signal }) =>
apiRequest<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
enabled: Boolean(caseNumber),
staleTime: 30_000,
refetchInterval: 60_000,
});
}
export function useWorkflowStatus(caseNumber: string | undefined) { export function useWorkflowStatus(caseNumber: string | undefined) {
return useQuery({ return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const, queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,

View File

@@ -1121,6 +1121,65 @@ async def api_case_create(req: CaseCreateRequest):
return parsed return parsed
@app.get("/api/cases/{case_number}/git-status")
async def api_case_git_status(case_number: str):
"""Git sync status for a case repo."""
case_dir = config.find_case_dir(case_number)
git_dir = case_dir / ".git"
if not git_dir.exists():
return {"synced": False, "error": "no_repo"}
env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "GIT_TERMINAL_PROMPT": "0"}
# Last commit info
log = subprocess.run(
["git", "log", "-1", "--format=%H%n%aI%n%s"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
lines = log.stdout.strip().splitlines() if log.returncode == 0 else []
last_commit_time = lines[1] if len(lines) > 1 else None
last_commit_msg = lines[2] if len(lines) > 2 else None
# Dirty files count
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
dirty = len([l for l in status.stdout.splitlines() if l.strip()]) if status.returncode == 0 else 0
# Check if remote exists and if we're ahead
has_remote = False
ahead = 0
remote_url = None
remote_check = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
if remote_check.returncode == 0:
has_remote = True
# Sanitize token from URL
raw = remote_check.stdout.strip()
remote_url = raw.split("@")[-1] if "@" in raw else raw
ahead_check = subprocess.run(
["git", "rev-list", "HEAD", "--not", "--remotes", "--count"],
cwd=case_dir, capture_output=True, text=True, env=env,
)
if ahead_check.returncode == 0:
ahead = int(ahead_check.stdout.strip() or "0")
synced = has_remote and dirty == 0 and ahead == 0
return {
"synced": synced,
"has_remote": has_remote,
"remote_url": remote_url,
"dirty_files": dirty,
"commits_ahead": ahead,
"last_commit_time": last_commit_time,
"last_commit_msg": last_commit_msg,
}
@app.get("/api/cases/{case_number}/details") @app.get("/api/cases/{case_number}/details")
async def api_case_get(case_number: str): async def api_case_get(case_number: str):
"""Get full case details including documents.""" """Get full case details including documents."""