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

View File

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

View File

@@ -3,14 +3,107 @@
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
from pathlib import Path
from uuid import UUID
import httpx
from legal_mcp import config
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(
case_number: str,
@@ -92,7 +185,7 @@ async def case_create(
case_dir.mkdir(parents=True, exist_ok=True)
docs_dir = case_dir / "documents"
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 / "proofread").mkdir(exist_ok=True)
(docs_dir / "backup").mkdir(exist_ok=True)
@@ -121,6 +214,12 @@ async def case_create(
except Exception:
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)

View File

@@ -4,13 +4,22 @@
CASES_DIR="/home/chaim/legal-ai/data/cases"
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
[ -d "$status_dir" ] || continue
for case_dir in "$status_dir"/*/; do
export GIT_AUTHOR_NAME="Ezer Mishpati"
export GIT_AUTHOR_EMAIL="legal@local"
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
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
# 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
git add -A 2>/dev/null
# Build commit message from changed files
changed_files=$(git diff --cached --name-only 2>/dev/null | head -5)
# Count changed files
count=$(git diff --cached --name-only 2>/dev/null | wc -l)
case_name=$(basename "$case_dir")
[ "$count" -eq 0 ] && continue
msg="סנכרון אוטומטי — ${count} קבצים שונו"
# Commit
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null
if [ $? -eq 0 ]; then
# Push (non-blocking, ignore errors)
git push origin main --quiet 2>/dev/null
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG"
if git commit -m "$msg" --quiet 2>/dev/null; then
# Push only if remote exists
if git remote get-url origin >/dev/null 2>&1; then
if git push origin HEAD --quiet 2>/dev/null; then
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
done
done

View File

@@ -25,6 +25,8 @@ import {
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { toast } from "sonner";
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
export default function SettingsPage() {
const { data: mappings, isPending: loadingMappings } = useTagMappings();
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
@@ -35,9 +37,9 @@ export default function SettingsPage() {
const [tagLabel, setTagLabel] = useState("");
const [companyId, setCompanyId] = useState("");
function handleTagChange(value: string) {
function handleTagInput(value: string) {
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);
}
@@ -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-col gap-1.5 min-w-[180px]">
<label className="text-[0.72rem] text-ink-muted">
תגית (appeal subtype)
תגית
</label>
<Select value={tag} onValueChange={handleTagChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="בחר סוג ערר" />
</SelectTrigger>
<SelectContent>
{APPEAL_SUBTYPES.filter((s) => s.value !== "unknown").map(
(s) => (
<SelectItem key={s.value} value={s.value}>
<Input
list="tag-suggestions"
value={tag}
onChange={(e) => handleTagInput(e.target.value)}
placeholder="סוג ערר או תגית חופשית"
className="w-[220px]"
/>
<datalist id="tag-suggestions">
{TAG_SUGGESTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</option>
))}
</datalist>
</div>
<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 { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
עודכן
</dt>
<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>
</div>
</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) {
return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,

View File

@@ -1121,6 +1121,65 @@ async def api_case_create(req: CaseCreateRequest):
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")
async def api_case_get(case_number: str):
"""Get full case details including documents."""