Compare commits
18 Commits
00d362ec5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded | |||
| 107be235f5 | |||
| 9e7d98a47a | |||
| 0a5be942ba | |||
| 67d5835eb8 | |||
| 1d8c4e29c8 | |||
| 943696efbd | |||
| 7d1fc49e35 | |||
| ad04291d39 | |||
| 8d0f8ad17b | |||
| cf7c918bed | |||
| a9db657541 | |||
| c7941a63ec | |||
| dede33cdff | |||
| e203474548 |
@@ -101,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||
```
|
||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||
|
||||
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||
|
||||
---
|
||||
|
||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||
|
||||
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||
- TaskMaster: `legal-ai` #149.
|
||||
|
||||
---
|
||||
|
||||
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||
|
||||
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||
"כל התורים ריקים — אין עבודה".
|
||||
|
||||
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||
ולכן זה נראה כאילו משהו נמסר.
|
||||
|
||||
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||
|
||||
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||
שאומת כמגיע לסוכן.
|
||||
|
||||
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||
|
||||
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
|
||||
@@ -29,12 +29,15 @@ import {
|
||||
useRunLog,
|
||||
useCancelRun,
|
||||
useResetAgentSession,
|
||||
useAgentHealth,
|
||||
useRecentEscalations,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
type AgentRun,
|
||||
type SubscriptionUsage,
|
||||
type UsageWindow,
|
||||
type AgentHealthState,
|
||||
} from "@/lib/api/operations";
|
||||
import { formatTime, israelParts } from "@/lib/format-date";
|
||||
|
||||
@@ -802,6 +805,154 @@ function LiveAgentsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
const HEALTH_LABEL: Record<AgentHealthState, string> = {
|
||||
zombie: "זומבי",
|
||||
stalled: "תקוע",
|
||||
working: "עובד",
|
||||
idle: "רגוע",
|
||||
};
|
||||
|
||||
function AgentHealthPanel() {
|
||||
const { data, isLoading } = useAgentHealth();
|
||||
const { data: esc } = useRecentEscalations();
|
||||
|
||||
const counts = data?.counts;
|
||||
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
|
||||
// An escalated issue is chair-owned, so it leaves this list; the escalations
|
||||
// card below is the handled-loop history.
|
||||
const problems = (data?.items ?? []).filter(
|
||||
(i) => i.health === "zombie" || i.health === "stalled",
|
||||
);
|
||||
const healthyWorking = counts?.working ?? 0;
|
||||
const healthyIdle = counts?.idle ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatTile
|
||||
label="זומבי"
|
||||
value={counts?.zombie ?? 0}
|
||||
tone="red"
|
||||
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
|
||||
/>
|
||||
<StatTile
|
||||
label="תקוע"
|
||||
value={counts?.stalled ?? 0}
|
||||
tone="amber"
|
||||
title="ננער חוזר בלי התקדמות"
|
||||
/>
|
||||
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
|
||||
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
|
||||
</div>
|
||||
|
||||
{problems.length === 0 ? (
|
||||
<p className="text-sm text-emerald-600">
|
||||
אין סוכן תקוע כרגע — כל הסוכנים תקינים.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{problems.map((p) => (
|
||||
<div
|
||||
key={p.issue_id}
|
||||
className={
|
||||
p.health === "zombie"
|
||||
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
|
||||
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Badge
|
||||
variant={p.health === "zombie" ? "destructive" : "outline"}
|
||||
className={
|
||||
p.health === "stalled"
|
||||
? "border-warn/50 text-warn font-normal"
|
||||
: "font-normal"
|
||||
}
|
||||
>
|
||||
{HEALTH_LABEL[p.health]}
|
||||
</Badge>
|
||||
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
|
||||
{p.identifier}
|
||||
</span>
|
||||
<span className="text-[0.82rem] text-navy font-semibold">
|
||||
{p.agent_name}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{p.recovery_wakeups > 0
|
||||
? `${p.recovery_wakeups} יקיצות-שחזור`
|
||||
: `${p.wakeups} יקיצות`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-ink-muted shrink-0">
|
||||
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-[0.78rem] text-ink-muted">
|
||||
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> —{" "}
|
||||
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
|
||||
<p className="text-ink-muted text-[0.72rem] mb-3">
|
||||
כל זומבי מתמשך (לולאת-recovery ≥2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
|
||||
— בלי להעיר סוכן מחדש.
|
||||
</p>
|
||||
{!esc?.items?.length ? (
|
||||
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{esc.items.map((e) => (
|
||||
<div
|
||||
key={`${e.issue_id}-${e.created_at ?? ""}`}
|
||||
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
|
||||
>
|
||||
<Badge
|
||||
variant={e.severity === "medium" ? "outline" : "destructive"}
|
||||
className={
|
||||
e.severity === "medium"
|
||||
? "border-gold/50 text-gold-deep font-normal shrink-0"
|
||||
: "font-normal shrink-0"
|
||||
}
|
||||
>
|
||||
{e.severity}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
|
||||
{e.identifier}
|
||||
</span>
|
||||
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
|
||||
</div>
|
||||
<span className="text-[0.68rem] text-ink-muted shrink-0">
|
||||
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { data, isLoading, error } = useOperations();
|
||||
|
||||
@@ -840,6 +991,9 @@ export default function OperationsPage() {
|
||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||
<LiveAgentsPanel />
|
||||
|
||||
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||
<AgentHealthPanel />
|
||||
|
||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||
<AgentAdaptersPanel />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Ban,
|
||||
Target,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Role → color mapping ────────────────────────────────────── */
|
||||
@@ -818,6 +821,312 @@ function IssueGroup({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Compose: directive box + target selector (top of tab) ────── */
|
||||
|
||||
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
|
||||
|
||||
/** Client mirror of the server's pick_default_comment_target
|
||||
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
|
||||
* CEO main issue), then any open issue, then any top-level, else newest.
|
||||
* `issues` arrives oldest→newest, so the last match is the newest. */
|
||||
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
|
||||
if (!issues.length) return null;
|
||||
const isTop = (i: PaperclipIssue) => i.parent_id == null;
|
||||
const preds: ((i: PaperclipIssue) => boolean)[] = [
|
||||
(i) => isOpenStatus(i.status) && isTop(i),
|
||||
(i) => isOpenStatus(i.status),
|
||||
isTop,
|
||||
];
|
||||
for (const p of preds) {
|
||||
const m = issues.filter(p);
|
||||
if (m.length) return m[m.length - 1];
|
||||
}
|
||||
return issues[issues.length - 1];
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
in_progress: "bg-emerald-100 text-emerald-700",
|
||||
in_review: "bg-amber-100 text-amber-700",
|
||||
todo: "bg-emerald-100 text-emerald-700",
|
||||
backlog: "bg-emerald-100 text-emerald-700",
|
||||
blocked: "bg-red-100 text-red-700",
|
||||
done: "bg-gray-100 text-gray-500",
|
||||
cancelled: "bg-red-50 text-red-600",
|
||||
};
|
||||
function statusTone(s: string) {
|
||||
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
/** Where a chair instruction is routed. `auto` lets the server pick the live
|
||||
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
|
||||
* CEO-owned run (bypasses the human-owned-issue cancellation). */
|
||||
type Target =
|
||||
| { kind: "auto" }
|
||||
| { kind: "issue"; id: string }
|
||||
| { kind: "new_run" };
|
||||
|
||||
function TargetSelector({
|
||||
issues,
|
||||
target,
|
||||
onChange,
|
||||
}: {
|
||||
issues: PaperclipIssue[];
|
||||
target: Target;
|
||||
onChange: (t: Target) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||
|
||||
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||
const effectiveIssue =
|
||||
target.kind === "issue"
|
||||
? issues.find((i) => i.id === target.id) ?? null
|
||||
: target.kind === "auto"
|
||||
? defaultIssue
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
|
||||
>
|
||||
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
{target.kind === "new_run" ? (
|
||||
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
|
||||
) : effectiveIssue ? (
|
||||
<>
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{effectiveIssue.identifier}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-navy truncate">
|
||||
{shortIssueTitle(effectiveIssue.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
|
||||
>
|
||||
{issueStatusLabel(effectiveIssue.status)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-ink-muted">בחר יעד</span>
|
||||
)}
|
||||
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות פעילות
|
||||
</div>
|
||||
{activeIssues.map((i) => {
|
||||
const selected =
|
||||
(target.kind === "issue" && target.id === i.id) ||
|
||||
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||
return (
|
||||
<button
|
||||
key={i.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "issue", id: i.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${selected ? "bg-gold/10" : ""}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
<span className="text-xs text-ink-soft truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
{selected && (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{closedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||
</div>
|
||||
{closedIssues.map((i) => (
|
||||
<div
|
||||
key={i.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
|
||||
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
<span className="text-xs text-ink-faint truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "new_run" });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start hover:bg-sand-soft"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-navy">
|
||||
פתח הרצה חדשה
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-muted">
|
||||
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({
|
||||
caseNumber,
|
||||
issues,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
issues: PaperclipIssue[];
|
||||
}) {
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const [target, setTarget] = useState<Target>({ kind: "auto" });
|
||||
|
||||
const selectedIssue =
|
||||
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
|
||||
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim() || closedTarget) return;
|
||||
const vars =
|
||||
target.kind === "new_run"
|
||||
? { body: body.trim(), new_run: true }
|
||||
: target.kind === "issue"
|
||||
? { body: body.trim(), issue_id: target.id }
|
||||
: { body: body.trim() };
|
||||
sendComment.mutate(vars, {
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
setTarget({ kind: "auto" });
|
||||
toast.success(
|
||||
res.new_run
|
||||
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
|
||||
: `נשלח ל-${res.issue_identifier}`,
|
||||
);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<MessageSquare className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
|
||||
<span className="text-[11px] text-ink-faint ms-auto">
|
||||
Ctrl+Enter לשליחה
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
|
||||
שליחה אל:
|
||||
</span>
|
||||
<TargetSelector issues={issues} target={target} onChange={setTarget} />
|
||||
</div>
|
||||
|
||||
{closedTarget && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן —
|
||||
ההוראה לא תטופל. בחר משימה פעילה או{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold underline"
|
||||
onClick={() => setTarget({ kind: "new_run" })}
|
||||
>
|
||||
פתח הרצה חדשה
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center mt-2">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ms-auto"
|
||||
onClick={handleSend}
|
||||
disabled={!body.trim() || closedTarget || sendComment.isPending}
|
||||
>
|
||||
{sendComment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שלח
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||
|
||||
export function AgentActivityFeed({
|
||||
@@ -826,9 +1135,6 @@ export function AgentActivityFeed({
|
||||
caseNumber: string;
|
||||
}) {
|
||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||
// and a fresh Map each render would defeat the child cards' memoization).
|
||||
@@ -838,27 +1144,6 @@ export function AgentActivityFeed({
|
||||
return m;
|
||||
}, [data?.issues]);
|
||||
|
||||
// Auto-scroll on new comments or interactions
|
||||
const commentCount = data?.comments?.length ?? 0;
|
||||
const interactionCount = data?.interactions?.length ?? 0;
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [commentCount, interactionCount]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim()) return;
|
||||
sendComment.mutate(
|
||||
{ body: body.trim() },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// ── Empty / loading states ──
|
||||
|
||||
if (isLoading) {
|
||||
@@ -939,14 +1224,18 @@ export function AgentActivityFeed({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* directive box — moved to the TOP, above the activity (most available,
|
||||
no scrolling). Carries the target selector. */}
|
||||
<Composer caseNumber={caseNumber} issues={data.issues} />
|
||||
|
||||
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||
<AgentRoster agents={data.agents ?? []} />
|
||||
|
||||
{/* pending interactions surfaced to the top — each an accordion, first
|
||||
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
||||
{/* pending interactions surfaced to the top — each an accordion, all
|
||||
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||
{pending.length > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך
|
||||
@@ -954,27 +1243,30 @@ export function AgentActivityFeed({
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i, idx) => (
|
||||
{pending.map((i) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={idx === 0}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline header */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
|
||||
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||
<span className="text-[0.72rem] text-ink-muted">
|
||||
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
|
||||
החדש למעלה
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-muted ms-auto">
|
||||
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-issue accordions */}
|
||||
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||
{totalFeedItems === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
@@ -989,56 +1281,17 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
issueGroups.map(({ iss, items }, idx) => (
|
||||
issueGroups.map(({ iss, items }) => (
|
||||
<IssueGroup
|
||||
key={iss.id}
|
||||
issue={iss}
|
||||
items={items}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={
|
||||
idx === 0 ||
|
||||
(iss.status !== "done" && iss.status !== "cancelled")
|
||||
}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · Ctrl+Enter לשליחה
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={!body.trim() || sendComment.isPending}
|
||||
>
|
||||
{sendComment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שלח
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useCaseFiles,
|
||||
useCaseFileText,
|
||||
caseFileUrl,
|
||||
type CaseFileFolder,
|
||||
} from "@/lib/api/case-files";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/format-date";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
const FOLDER_LABELS: Record<string, string> = {
|
||||
research: "מחקר וטיוטות",
|
||||
originals: "מסמכי מקור",
|
||||
extracted: "טקסט מחולץ (OCR)",
|
||||
proofread: "לאחר הגהה",
|
||||
drafts: "טיוטות DOCX",
|
||||
backup: "גיבויים",
|
||||
};
|
||||
|
||||
// Extensions the inline viewer can render (everything else → download only).
|
||||
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
|
||||
|
||||
function fileExt(filename: string): string {
|
||||
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function folderLabel(f: CaseFileFolder): string {
|
||||
return FOLDER_LABELS[f.name] ?? f.name;
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const EXT_TONE: Record<string, string> = {
|
||||
md: "bg-teal-100 text-teal-700",
|
||||
markdown: "bg-teal-100 text-teal-700",
|
||||
txt: "bg-blue-100 text-blue-700",
|
||||
json: "bg-blue-100 text-blue-700",
|
||||
pdf: "bg-red-100 text-red-700",
|
||||
docx: "bg-indigo-100 text-indigo-700",
|
||||
doc: "bg-indigo-100 text-indigo-700",
|
||||
};
|
||||
function extTone(ext: string): string {
|
||||
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
type Selected = { folderKey: string; filename: string };
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
|
||||
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isLoading, error } = useCaseFiles(caseNumber);
|
||||
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
|
||||
|
||||
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
|
||||
|
||||
// Default selection derived (not via an effect): a text file in research,
|
||||
// else any text file. The chair's explicit pick overrides it.
|
||||
const defaultSelected = useMemo<Selected | null>(() => {
|
||||
const pick = (
|
||||
pred: (f: CaseFileFolder, name: string) => boolean,
|
||||
): Selected | null => {
|
||||
for (const f of folders)
|
||||
for (const file of f.files)
|
||||
if (pred(f, file.filename))
|
||||
return { folderKey: f.key, filename: file.filename };
|
||||
return null;
|
||||
};
|
||||
return (
|
||||
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
|
||||
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
|
||||
);
|
||||
}, [folders]);
|
||||
|
||||
const [userSelected, setUserSelected] = useState<Selected | null>(null);
|
||||
const selected = userSelected ?? defaultSelected;
|
||||
|
||||
// A folder is open if explicitly toggled; otherwise the folder of the current
|
||||
// selection (or the first non-empty folder) is open by default.
|
||||
const defaultOpenKey =
|
||||
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
|
||||
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
|
||||
new Map(),
|
||||
);
|
||||
const isOpen = (key: string) =>
|
||||
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
|
||||
|
||||
const selExt = selected ? fileExt(selected.filename) : "";
|
||||
const selIsText = TEXT_EXT.has(selExt);
|
||||
const {
|
||||
data: fileText,
|
||||
isLoading: textLoading,
|
||||
error: textError,
|
||||
} = useCaseFileText(
|
||||
caseNumber,
|
||||
selIsText ? selected?.folderKey : undefined,
|
||||
selIsText ? selected?.filename : undefined,
|
||||
);
|
||||
|
||||
const toggleFolder = (key: string) => {
|
||||
const next = !isOpen(key);
|
||||
setOpenOverrides((prev) => new Map(prev).set(key, next));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
<span>טוען קבצים...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-10 text-red-500 text-sm">
|
||||
שגיאה בטעינת קבצי-התיק
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-rule rounded-xl overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||
<FolderOpen className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
|
||||
<span className="text-[11px] text-ink-muted ms-auto">
|
||||
כל תתי-התיקיות · {totalFiles} קבצים
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-[320px_1fr]">
|
||||
{/* file tree */}
|
||||
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
|
||||
{folders.map((f) => {
|
||||
const open = isOpen(f.key);
|
||||
return (
|
||||
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(f.key)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
|
||||
>
|
||||
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<span className="text-[0.78rem] font-bold text-navy">
|
||||
{folderLabel(f)}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
|
||||
{f.path}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
|
||||
>
|
||||
{f.files.length || "ריק"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && f.files.length > 0 && (
|
||||
<div className="pb-1 bg-parchment/50">
|
||||
{f.files.map((file) => {
|
||||
const ext = fileExt(file.filename);
|
||||
const isSel =
|
||||
selected?.folderKey === f.key &&
|
||||
selected?.filename === file.filename;
|
||||
const viewable = TEXT_EXT.has(ext);
|
||||
return (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
|
||||
onClick={() =>
|
||||
viewable
|
||||
? setUserSelected({
|
||||
folderKey: f.key,
|
||||
filename: file.filename,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
|
||||
>
|
||||
{viewable ? (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11.5px] font-medium text-ink-soft truncate">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="text-[9.5px] text-ink-light">
|
||||
{fmtSize(file.size)} ·{" "}
|
||||
{formatDateTime(file.modified_at * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(caseNumber, f.key, file.filename)}
|
||||
download={file.filename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
|
||||
title="הורדה"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{folders.length === 0 && (
|
||||
<div className="text-center py-10 text-ink-faint text-sm">
|
||||
אין קבצים בתיק.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* viewer */}
|
||||
<div className="min-w-0 flex flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
|
||||
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||||
בחר קובץ טקסט מהרשימה לצפייה
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] font-bold text-navy truncate">
|
||||
{selected.filename}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(
|
||||
caseNumber,
|
||||
selected.folderKey,
|
||||
selected.filename,
|
||||
)}
|
||||
download={selected.filename}
|
||||
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
הורדה
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
|
||||
{!selIsText ? (
|
||||
<div className="text-center py-12 text-sm text-ink-muted">
|
||||
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge> —
|
||||
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
|
||||
</div>
|
||||
) : textLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
טוען תוכן...
|
||||
</div>
|
||||
) : textError ? (
|
||||
<div className="text-center py-12 text-red-500 text-sm">
|
||||
שגיאה בטעינת תוכן הקובץ
|
||||
</div>
|
||||
) : selExt === "md" || selExt === "markdown" ? (
|
||||
<Markdown content={fileText ?? ""} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
|
||||
{fileText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
||||
completed_at: string | null;
|
||||
created_at: string | null;
|
||||
company_id: string;
|
||||
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||
parent_id: string | null;
|
||||
};
|
||||
|
||||
export type PaperclipComment = {
|
||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
||||
export function useSendComment(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||
apiRequest<{
|
||||
comment_id: string | null;
|
||||
issue_id: string;
|
||||
issue_identifier: string;
|
||||
issue_status?: string;
|
||||
new_run?: boolean;
|
||||
}>(
|
||||
`/api/cases/${caseNumber}/agents/comment`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
|
||||
66
web-ui/src/lib/api/case-files.ts
Normal file
66
web-ui/src/lib/api/case-files.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Case files API — browse & read every content subfolder of a case on disk.
|
||||
*
|
||||
* Surfaces the agent-produced work files (drafts, OCR text, research, originals…)
|
||||
* that previously lived only on the server filesystem. Backed by the pre-existing
|
||||
* `/local-files` endpoints, generalized to all subfolders (path-safe on the
|
||||
* server via `_resolve_case_file`).
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type CaseFile = {
|
||||
filename: string;
|
||||
size: number;
|
||||
/** Unix epoch seconds (float) of last modification. */
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
export type CaseFileFolder = {
|
||||
/** Opaque folder key for the read endpoint (e.g. "documents__research"). */
|
||||
key: string;
|
||||
/** Leaf folder name (e.g. "research"). */
|
||||
name: string;
|
||||
/** Case-relative path (e.g. "documents/research"). */
|
||||
path: string;
|
||||
files: CaseFile[];
|
||||
};
|
||||
|
||||
export type CaseFilesResponse = { folders: CaseFileFolder[] };
|
||||
|
||||
export function useCaseFiles(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["case-files", caseNumber ?? ""],
|
||||
queryFn: () =>
|
||||
apiRequest<CaseFilesResponse>(`/api/cases/${caseNumber}/local-files`),
|
||||
enabled: !!caseNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/** Direct URL to a single file — use as an <a href> for download/open. */
|
||||
export function caseFileUrl(
|
||||
caseNumber: string,
|
||||
folderKey: string,
|
||||
filename: string,
|
||||
): string {
|
||||
return `/api/cases/${encodeURIComponent(caseNumber)}/local-files/${encodeURIComponent(folderKey)}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
/** Fetch a text file's contents (md/txt/json) for the inline viewer. */
|
||||
export function useCaseFileText(
|
||||
caseNumber: string | undefined,
|
||||
folderKey: string | undefined,
|
||||
filename: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["case-file-text", caseNumber ?? "", folderKey ?? "", filename ?? ""],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(caseFileUrl(caseNumber!, folderKey!, filename!));
|
||||
if (!res.ok) throw new Error(`שגיאה בטעינת הקובץ (${res.status})`);
|
||||
return res.text();
|
||||
},
|
||||
enabled: !!caseNumber && !!folderKey && !!filename,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -190,6 +190,61 @@ export function useAgentRuns() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
|
||||
|
||||
export type AgentHealthItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
status: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
health: AgentHealthState;
|
||||
wakeups: number;
|
||||
recovery_wakeups: number;
|
||||
};
|
||||
|
||||
export type AgentHealthResponse = {
|
||||
ok: boolean;
|
||||
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
|
||||
counts: Record<AgentHealthState, number>;
|
||||
};
|
||||
|
||||
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
|
||||
export function useAgentHealth() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "health"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
|
||||
refetchInterval: 5000, // live alongside the running-agents view
|
||||
staleTime: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export type EscalationItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
severity: "critical" | "high" | "medium";
|
||||
reason: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type RecentEscalationsResponse = {
|
||||
ok: boolean;
|
||||
items: EscalationItem[]; // newest-first
|
||||
};
|
||||
|
||||
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
|
||||
export function useRecentEscalations() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "escalations"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
|
||||
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
|
||||
staleTime: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full output log of one run — fetched on demand (drawer open). */
|
||||
export function useRunLog(runId: string | null) {
|
||||
return useQuery({
|
||||
|
||||
@@ -77,3 +77,34 @@ def classify_issue_health(
|
||||
if total_wakeups >= STALL_WAKEUP_THRESHOLD:
|
||||
return "stalled"
|
||||
return "idle"
|
||||
|
||||
|
||||
# A zombie needs at least this many recovery-marker wakeups before the watchdog
|
||||
# auto-escalates it — filters a single transient recovery wake from a genuine,
|
||||
# budget-burning loop. Escalation reassigns the issue to the chair (via
|
||||
# escalate_issue), so an escalated issue is agent-unowned next sweep and never
|
||||
# re-escalates (idempotent by construction).
|
||||
ZOMBIE_ESCALATE_MIN_RECOVERY = 2
|
||||
|
||||
|
||||
def zombie_escalation(item: dict) -> tuple[str, str] | None:
|
||||
"""Decide whether a health item warrants auto-escalation to the chair.
|
||||
|
||||
Pure (no I/O) so the watchdog's policy is unit-tested. Returns
|
||||
``(severity, reason)`` for a persistent zombie, else ``None``. Severity
|
||||
scales with loop intensity — a long-running loop (``>=5`` recovery wakes)
|
||||
is ``high``, a fresh one ``medium``. Only ``zombie`` items escalate: a
|
||||
``stalled``/``working``/``idle`` issue is not a recovery loop.
|
||||
"""
|
||||
if item.get("health") != "zombie":
|
||||
return None
|
||||
recovery = int(item.get("recovery_wakeups", 0))
|
||||
if recovery < ZOMBIE_ESCALATE_MIN_RECOVERY:
|
||||
return None
|
||||
severity = "high" if recovery >= 5 else "medium"
|
||||
reason = (
|
||||
f"auto-escalation (watchdog): זוהתה לולאת-recovery — {recovery} יקיצות-שחזור "
|
||||
f"על {item.get('identifier')} ({item.get('agent_name')}); ה-issue נמסר לחיים "
|
||||
f"במקום להמשיך לשרוף budget."
|
||||
)
|
||||
return severity, reason
|
||||
|
||||
@@ -41,9 +41,13 @@ from web.paperclip_client import (
|
||||
create_project as pc_create_project,
|
||||
create_workflow_issue as pc_create_workflow_issue,
|
||||
get_agent_health as pc_get_agent_health,
|
||||
get_recent_escalations as pc_get_recent_escalations,
|
||||
get_predecessor_context as pc_get_predecessor_context,
|
||||
get_predecessor_for_case as pc_get_predecessor_for_case,
|
||||
get_agents_for_case as pc_get_agents_for_case,
|
||||
get_agents_for_company as pc_get_agents,
|
||||
get_case_issues as pc_get_case_issues,
|
||||
pick_default_comment_target as pc_pick_default_comment_target,
|
||||
get_issue_comments as pc_get_issue_comments,
|
||||
get_issue_interactions as pc_get_issue_interactions,
|
||||
get_project_url,
|
||||
@@ -59,6 +63,7 @@ from web.paperclip_client import (
|
||||
cancel_interaction as _cancel_interaction,
|
||||
cancel_run as _cancel_run,
|
||||
escalate_issue as _escalate_issue,
|
||||
mark_comment_routed as _mark_comment_routed,
|
||||
post_comment as _post_comment,
|
||||
reap_stale_interactions as _reap_stale_interactions,
|
||||
reset_agent_session as _reset_agent_session,
|
||||
@@ -68,6 +73,7 @@ from web.paperclip_client import (
|
||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||
wake_ceo_agent as _wake_ceo,
|
||||
wake_ceo_for_action as _wake_ceo_for_action,
|
||||
open_ceo_run as _open_ceo_run,
|
||||
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
||||
wake_curator_for_final as _wake_curator_for_final,
|
||||
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
||||
@@ -85,6 +91,9 @@ pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
|
||||
pc_wake_ceo_for_action = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
||||
)(_wake_ceo_for_action)
|
||||
pc_open_ceo_run = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id"),
|
||||
)(_open_ceo_run)
|
||||
pc_wake_ceo_for_feedback_fold = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
||||
)(_wake_ceo_for_feedback_fold)
|
||||
@@ -106,6 +115,9 @@ pc_wake_analyst_for_protocol_analysis = instrument(
|
||||
pc_post_comment = instrument(
|
||||
"agent.comment", keys=("issue_id", "company_id"),
|
||||
)(_post_comment)
|
||||
pc_mark_comment_routed = instrument(
|
||||
"agent.comment", keys=("issue_id", "comment_id"),
|
||||
)(_mark_comment_routed)
|
||||
pc_cancel_interaction = instrument(
|
||||
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
||||
)(_cancel_interaction)
|
||||
@@ -155,11 +167,13 @@ __all__ = [
|
||||
# issues / workflow
|
||||
"pc_create_workflow_issue",
|
||||
"pc_get_case_issues",
|
||||
"pc_pick_default_comment_target",
|
||||
# agents / wakeups
|
||||
"pc_get_agents_for_case",
|
||||
"pc_get_agents",
|
||||
"pc_wake_ceo",
|
||||
"pc_wake_ceo_for_action",
|
||||
"pc_open_ceo_run",
|
||||
"pc_wake_ceo_for_feedback_fold",
|
||||
"pc_wake_curator_for_final",
|
||||
"pc_wake_for_precedent_extraction",
|
||||
@@ -169,6 +183,7 @@ __all__ = [
|
||||
"pc_get_generation_run_status",
|
||||
# comments / interactions
|
||||
"pc_post_comment",
|
||||
"pc_mark_comment_routed",
|
||||
"pc_get_issue_comments",
|
||||
"pc_get_issue_interactions",
|
||||
"pc_accept_interaction",
|
||||
@@ -179,6 +194,9 @@ __all__ = [
|
||||
"pc_escalate_issue",
|
||||
# agent-run observability + control (live view + smart management)
|
||||
"pc_get_agent_health",
|
||||
"pc_get_recent_escalations",
|
||||
"pc_get_predecessor_context",
|
||||
"pc_get_predecessor_for_case",
|
||||
"pc_list_live_runs",
|
||||
"pc_get_run_log",
|
||||
"pc_get_run_events",
|
||||
|
||||
294
web/app.py
294
web/app.py
@@ -63,9 +63,13 @@ from web.agent_platform_port import (
|
||||
pc_create_workflow_issue,
|
||||
pc_escalate_issue,
|
||||
pc_get_agent_health,
|
||||
pc_get_recent_escalations,
|
||||
pc_get_predecessor_context,
|
||||
pc_get_predecessor_for_case,
|
||||
pc_get_agents,
|
||||
pc_get_agents_for_case,
|
||||
pc_get_case_issues,
|
||||
pc_pick_default_comment_target,
|
||||
pc_get_issue_comments,
|
||||
pc_get_issue_interactions,
|
||||
pc_get_run_events,
|
||||
@@ -86,6 +90,8 @@ from web.agent_platform_port import (
|
||||
rename_case_project,
|
||||
pc_wake_ceo,
|
||||
pc_wake_ceo_for_action,
|
||||
pc_open_ceo_run,
|
||||
pc_mark_comment_routed,
|
||||
pc_wake_ceo_for_feedback_fold,
|
||||
pc_wake_curator_for_final,
|
||||
pc_wake_for_precedent_extraction,
|
||||
@@ -123,18 +129,66 @@ async def _interaction_reaper_loop():
|
||||
logger.warning("interaction reaper sweep failed", exc_info=True)
|
||||
|
||||
|
||||
# Watchdog kill-switch — set AGENT_ZOMBIE_AUTO_ESCALATE=0 to observe health
|
||||
# without acting (escalation stays available manually via the endpoint).
|
||||
_ZOMBIE_AUTO_ESCALATE = os.environ.get("AGENT_ZOMBIE_AUTO_ESCALATE", "1") != "0"
|
||||
|
||||
|
||||
async def _zombie_escalation_loop():
|
||||
"""Watchdog: auto-escalate persistent recovery-loop zombies to the chair (#218+#222).
|
||||
|
||||
Every 15 min, derive per-issue health (#222) and hand any persistent zombie
|
||||
(a recovery loop, ``>=`` ``ZOMBIE_ESCALATE_MIN_RECOVERY`` recovery wakeups)
|
||||
to the human via the loop-safe escalate primitive (#218) — closing the loop
|
||||
that today burns budget until a human notices. Server-side on purpose: an
|
||||
agent stuck in a recovery loop cannot reliably self-escalate. Idempotent —
|
||||
an escalated issue is agent-unowned next sweep, so it is not re-escalated.
|
||||
"""
|
||||
from web.agent_health import zombie_escalation
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(900)
|
||||
if not _ZOMBIE_AUTO_ESCALATE:
|
||||
continue
|
||||
health = await pc_get_agent_health()
|
||||
for item in health.get("items", []):
|
||||
decision = zombie_escalation(item)
|
||||
if decision is None:
|
||||
continue
|
||||
severity, reason = decision
|
||||
result = await pc_escalate_issue(item["issue_id"], severity, reason)
|
||||
if result.get("ok"):
|
||||
logger.warning(
|
||||
"watchdog auto-escalated %s (%s, %d recovery wakeups) to chair [%s]",
|
||||
item.get("identifier"), item.get("agent_name"),
|
||||
item.get("recovery_wakeups", 0), severity,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"watchdog escalation failed for %s: %s",
|
||||
item.get("identifier"), result.get("error"),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning("zombie escalation sweep failed", exc_info=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
await db.init_schema()
|
||||
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
||||
reaper_task = asyncio.create_task(_interaction_reaper_loop())
|
||||
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sync_task.cancel()
|
||||
reaper_task.cancel()
|
||||
for _t in (sync_task, reaper_task):
|
||||
watchdog_task.cancel()
|
||||
for _t in (sync_task, reaper_task, watchdog_task):
|
||||
try:
|
||||
await _t
|
||||
except asyncio.CancelledError:
|
||||
@@ -3016,42 +3070,82 @@ async def api_learn(case_number: str):
|
||||
# ── Local files API — research, drafts, proofread ──
|
||||
|
||||
|
||||
# Directories under a case that are infrastructure/cache, not chair-facing
|
||||
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
|
||||
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
|
||||
|
||||
|
||||
def _is_content_dir(p: Path) -> bool:
|
||||
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
|
||||
|
||||
|
||||
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Enumerate every content subdirectory under a case dir as (key, path).
|
||||
|
||||
Covers *all* subfolders (not just research) — recursing one level into
|
||||
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
|
||||
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
|
||||
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
|
||||
against this same enumeration, so the key set is the serve allowlist (no path
|
||||
traversal).
|
||||
"""
|
||||
folders: list[tuple[str, Path]] = []
|
||||
if not case_dir.exists():
|
||||
return folders
|
||||
for child in sorted(case_dir.iterdir()):
|
||||
if not _is_content_dir(child):
|
||||
continue
|
||||
if child.name == "documents":
|
||||
folders.extend(
|
||||
(f"documents__{sub.name}", sub)
|
||||
for sub in sorted(child.iterdir())
|
||||
if _is_content_dir(sub)
|
||||
)
|
||||
else:
|
||||
folders.append((child.name, child))
|
||||
return folders
|
||||
|
||||
|
||||
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
|
||||
"""Safely resolve (folder_key, filename) to a file inside the case dir.
|
||||
|
||||
Returns None unless ``folder_key`` is one of the enumerated content folders
|
||||
AND ``filename`` resolves to a regular file directly inside it (no traversal).
|
||||
"""
|
||||
base = dict(_case_file_folders(case_dir)).get(folder_key)
|
||||
if base is None:
|
||||
return None
|
||||
target = (base / filename).resolve()
|
||||
if target.parent != base.resolve() or not target.is_file():
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files")
|
||||
async def api_local_files(case_number: str):
|
||||
"""List local files from case subdirectories (research, drafts, proofread)."""
|
||||
"""List local files across ALL content subfolders under a case (not just
|
||||
research) — so the chair can reach any agent-produced file (drafts, OCR text,
|
||||
originals…) from the UI instead of only over SSH. Empty folders are returned
|
||||
too (with an empty ``files`` list) so the UI can show them as such."""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
result = {}
|
||||
for folder in ("research", "proofread"):
|
||||
folder_path = case_dir / "documents" / folder
|
||||
if folder_path.exists():
|
||||
files = []
|
||||
for f in sorted(folder_path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": folder,
|
||||
})
|
||||
if files:
|
||||
result[folder] = files
|
||||
# Drafts are at case level, not under documents
|
||||
drafts_path = case_dir / "drafts"
|
||||
if drafts_path.exists():
|
||||
folders = []
|
||||
for key, path in _case_file_folders(case_dir):
|
||||
files = []
|
||||
for f in sorted(drafts_path.iterdir()):
|
||||
for f in sorted(path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": "drafts",
|
||||
})
|
||||
if files:
|
||||
result["drafts"] = files
|
||||
return result
|
||||
folders.append({
|
||||
"key": key,
|
||||
"name": path.name,
|
||||
"path": str(path.relative_to(case_dir)),
|
||||
"files": files,
|
||||
})
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
async def serve_blob(
|
||||
@@ -3121,17 +3215,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
"""Read contents of a local case file."""
|
||||
if folder not in ("research", "proofread", "drafts"):
|
||||
raise HTTPException(400, "Invalid folder")
|
||||
"""Serve a single local case file from any content subfolder.
|
||||
|
||||
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
|
||||
``_resolve_case_file`` validates it against the folder allowlist and blocks
|
||||
path traversal before the bytes are served.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if folder == "drafts":
|
||||
path = case_dir / "drafts" / filename
|
||||
else:
|
||||
path = case_dir / "documents" / folder / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
path = _resolve_case_file(case_dir, folder, filename)
|
||||
if path is None:
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
media_type = _local_file_media_type(filename)
|
||||
return await serve_blob(path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
def _local_file_media_type(filename: str) -> str:
|
||||
"""Best-effort content type for a served case file (text renders inline,
|
||||
binaries download)."""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return {
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
"txt": "text/plain; charset=utf-8",
|
||||
"json": "application/json; charset=utf-8",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"doc": "application/msword",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||
@@ -4552,14 +4661,39 @@ async def api_case_agents(case_number: str):
|
||||
class AgentCommentRequest(BaseModel):
|
||||
body: str
|
||||
issue_id: str | None = None
|
||||
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
|
||||
# the agents-tab target selector; the default path below now opens a run too, so
|
||||
# this only differs in *not* recording the instruction on the parent thread.
|
||||
new_run: bool = False
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/comment")
|
||||
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
"""Post a comment on a Paperclip issue linked to a case.
|
||||
"""Post a chair instruction to a Paperclip issue linked to a case.
|
||||
|
||||
If issue_id is omitted, the most recent non-done issue is used.
|
||||
Routing (agents-tab target selector):
|
||||
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
|
||||
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
|
||||
a human-owned parent. Overrides ``issue_id``.
|
||||
- ``issue_id`` given → post to that issue (explicit chair choice).
|
||||
- neither → prefer the live CEO main issue, never a closed one
|
||||
(``pick_default_comment_target``).
|
||||
"""
|
||||
if req.new_run:
|
||||
result = await pc_open_ceo_run(case_number, req.body)
|
||||
if result.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
|
||||
)
|
||||
return {
|
||||
"comment_id": None,
|
||||
"issue_id": result["issue_id"],
|
||||
"issue_identifier": result.get("identifier", ""),
|
||||
"issue_status": "in_progress",
|
||||
"new_run": True,
|
||||
}
|
||||
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not issues:
|
||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||
@@ -4569,14 +4703,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
if not target:
|
||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||
else:
|
||||
# Pick the most recent non-done issue, or the last one
|
||||
active = [i for i in issues if i["status"] != "done"]
|
||||
target = active[-1] if active else issues[-1]
|
||||
target = pc_pick_default_comment_target(issues)
|
||||
|
||||
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||
|
||||
# Find the identifier for the response
|
||||
# The comment alone reaches nobody: a case issue awaiting the chair is
|
||||
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
|
||||
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
|
||||
# The plugin sweep that was meant to catch this delivers the instruction as
|
||||
# `payload.prompt`, which the runner drops. So the comment is recorded for the
|
||||
# thread, and the instruction is carried by a CEO-owned child run — the one
|
||||
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
|
||||
run = await pc_open_ceo_run(case_number, req.body)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
# Claim the comment so the sweep does not re-route it: the CEO answers on the
|
||||
# child issue, leaving the parent with no agent reply, which the sweep reads
|
||||
# as "still pending" forever.
|
||||
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
|
||||
|
||||
# Echo the resolved target so the UI can show where it landed and flag it
|
||||
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
|
||||
result["issue_identifier"] = target.get("identifier", "")
|
||||
result["issue_status"] = target.get("status", "")
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
@@ -4593,8 +4748,10 @@ async def api_post_interaction_response(
|
||||
):
|
||||
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||
|
||||
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||
auto-wakes the issue assignee after a successful submission.
|
||||
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
|
||||
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
|
||||
issue is owned by the chair while it waits for her, so the "assignee" it wakes
|
||||
is a person and no agent ever runs (TaskMaster #228).
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
@@ -4606,7 +4763,7 @@ async def api_post_interaction_response(
|
||||
"reject": pc_reject_interaction,
|
||||
}
|
||||
try:
|
||||
return await handlers[req.action](
|
||||
result = await handlers[req.action](
|
||||
req.issue_id, req.interaction_id, req.payload,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -4615,6 +4772,23 @@ async def api_post_interaction_response(
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||
|
||||
answer = json.dumps(req.payload, ensure_ascii=False)
|
||||
run = await pc_open_ceo_run(
|
||||
case_number,
|
||||
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
|
||||
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
|
||||
)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
class InteractionDismissRequest(BaseModel):
|
||||
issue_id: str
|
||||
@@ -7544,6 +7718,38 @@ async def operations_agent_health():
|
||||
return await pc_get_agent_health()
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/escalations")
|
||||
async def operations_agent_escalations(limit: int = 10, hours: int = 24):
|
||||
"""Recent chair escalations — watchdog + manual (#218/#222).
|
||||
|
||||
Read-only history of what was handed to the chair, for the ops health panel.
|
||||
"""
|
||||
return await pc_get_recent_escalations(limit, hours)
|
||||
|
||||
|
||||
@app.get("/api/operations/issues/{issue_id}/predecessor")
|
||||
async def operations_issue_predecessor(issue_id: str, limit: int = 3):
|
||||
"""Recent finished runs on an issue for session continuation (#220, "seance").
|
||||
|
||||
Read-only. Lets a fresh heartbeat read what its predecessors on the same
|
||||
issue concluded (the run ``summary`` each leaves) instead of rediscovering
|
||||
context from scratch. Agent-facing wiring (MCP tool + HEARTBEAT) is a
|
||||
follow-up; this is the backend it will call.
|
||||
"""
|
||||
return await pc_get_predecessor_context(issue_id, limit)
|
||||
|
||||
|
||||
@app.get("/api/operations/cases/{case_number}/predecessor")
|
||||
async def operations_case_predecessor(case_number: str, limit: int = 5):
|
||||
"""Recent finished runs across a case's issues (#220, agent-facing "seance").
|
||||
|
||||
Case-scoped sibling of the by-issue endpoint — the plugin tool
|
||||
``legal_predecessor_context`` calls this so a resuming agent reads what prior
|
||||
sessions on the case concluded (agents key on case_number, not issue UUID).
|
||||
"""
|
||||
return await pc_get_predecessor_for_case(case_number, limit)
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/runs/{run_id}/log")
|
||||
async def operations_agent_run_log(run_id: str):
|
||||
"""Full output log (NDJSON stream) of one heartbeat run."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
@@ -326,6 +327,40 @@ async def _create_issue(
|
||||
return issue_id, identifier
|
||||
|
||||
|
||||
async def mark_comment_routed(issue_id: str, comment_id: str) -> dict:
|
||||
"""Claim a chair comment as already routed, so the plugin sweep skips it.
|
||||
|
||||
The plugin's `route-pending-comments` sweep re-routes any user comment newer
|
||||
than the newest agent comment on the same issue. A chair instruction answered
|
||||
on a CEO **child** issue leaves the parent with no agent reply, so the sweep
|
||||
would keep re-routing it forever. Writing the sweep's own marker
|
||||
(`last-routed-comment-id`, plugin state, issue scope) is what tells it the
|
||||
comment is handled — this is the same key `markCommentRouted` sets in
|
||||
`plugin-legal-ai/src/worker.ts`.
|
||||
"""
|
||||
if not comment_id:
|
||||
return {"ok": False, "error": "no_comment_id"}
|
||||
try:
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
|
||||
VALUES ($1::uuid, 'issue', $2, 'default', 'last-routed-comment-id', $3::jsonb)
|
||||
ON CONFLICT (plugin_id, scope_kind, scope_id, namespace, state_key)
|
||||
DO UPDATE SET value_json = $3::jsonb""",
|
||||
PLUGIN_ID, issue_id, json.dumps(comment_id),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
return {"ok": True, "issue_id": issue_id, "comment_id": comment_id}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"mark_comment_routed failed for issue %s comment %s: %s",
|
||||
issue_id, comment_id, e,
|
||||
)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None:
|
||||
"""Store the legal-ai case number in plugin state, linked to the issue."""
|
||||
await conn.execute(
|
||||
@@ -483,7 +518,8 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
||||
"""SELECT DISTINCT ON (i.id)
|
||||
i.id, i.title, i.status, i.identifier, i.priority,
|
||||
i.assignee_agent_id, a.name AS assignee_name,
|
||||
i.started_at, i.completed_at, i.created_at, i.company_id
|
||||
i.started_at, i.completed_at, i.created_at, i.company_id,
|
||||
i.parent_id
|
||||
FROM issues i
|
||||
LEFT JOIN agents a ON i.assignee_agent_id = a.id
|
||||
LEFT JOIN plugin_state ps ON ps.scope_id = i.id::text
|
||||
@@ -509,6 +545,7 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
||||
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
"company_id": str(r["company_id"]),
|
||||
"parent_id": str(r["parent_id"]) if r["parent_id"] else None,
|
||||
}
|
||||
for r in sorted_rows
|
||||
]
|
||||
@@ -516,6 +553,43 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
||||
await conn.close()
|
||||
|
||||
|
||||
# A comment posted to a closed issue never wakes an agent — Paperclip skips the
|
||||
# wakeup for done/cancelled issues, so the chair's instruction is silently
|
||||
# swallowed. The default-target picker MUST therefore avoid these statuses.
|
||||
CLOSED_ISSUE_STATUSES = frozenset({"done", "cancelled"})
|
||||
|
||||
|
||||
def pick_default_comment_target(issues: list[dict]) -> dict:
|
||||
"""Choose which issue a chair comment routes to when none is specified.
|
||||
|
||||
``issues`` is ordered oldest→newest (see :func:`get_case_issues`). Comment
|
||||
routing is meant to reach the CEO, whose live "התחל תהליך ניסוח" issue is
|
||||
top-level (``parent_id is None``) and open. Preference order:
|
||||
1. newest OPEN top-level issue — the live CEO main issue;
|
||||
2. newest OPEN issue of any depth — a live sub-issue if no main is open;
|
||||
3. newest top-level issue even if closed — better than a closed child;
|
||||
4. newest issue overall — last resort.
|
||||
Excluding closed statuses is the fix for the recurring bug where the newest
|
||||
non-done issue was a *cancelled* child, so the wakeup was skipped and the
|
||||
instruction vanished. ``issues`` must be non-empty.
|
||||
"""
|
||||
def is_open(i: dict) -> bool:
|
||||
return i["status"] not in CLOSED_ISSUE_STATUSES
|
||||
|
||||
def is_top(i: dict) -> bool:
|
||||
return i.get("parent_id") is None
|
||||
|
||||
for pred in (
|
||||
lambda i: is_open(i) and is_top(i),
|
||||
is_open,
|
||||
is_top,
|
||||
):
|
||||
matches = [i for i in issues if pred(i)]
|
||||
if matches:
|
||||
return matches[-1]
|
||||
return issues[-1]
|
||||
|
||||
|
||||
async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
|
||||
"""Get all comments on a list of Paperclip issues, with agent metadata."""
|
||||
if not issue_ids:
|
||||
@@ -615,10 +689,14 @@ async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dic
|
||||
|
||||
|
||||
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
"""Post a comment on a Paperclip issue.
|
||||
"""Post a comment on a Paperclip issue. Records only — wakes nobody.
|
||||
|
||||
Tries the Board API first (triggers plugin events for CEO routing).
|
||||
Falls back to direct DB insert + CEO wakeup if API fails.
|
||||
Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
|
||||
function used to also wake the CEO on ``issue_id``, but that wakeup is dead on
|
||||
arrival for the case issues the chair actually comments on: they are
|
||||
`in_review` and owned by her, so Paperclip cancels the run with
|
||||
`issue_assignee_changed` before it starts. Keeping it would leave two parallel
|
||||
delivery paths — one that works and one that silently doesn't (INV-G2).
|
||||
"""
|
||||
# Try Board API first — this triggers the event bus
|
||||
if PAPERCLIP_BOARD_API_KEY:
|
||||
@@ -635,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
except Exception:
|
||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||
|
||||
# Fallback: direct DB insert + explicit CEO wakeup
|
||||
comment_id = str(uuid.uuid4())
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
@@ -648,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Wake the correct CEO for this company
|
||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json={
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
"reason": f"user_comment_{issue_id}",
|
||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
||||
|
||||
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||
|
||||
|
||||
@@ -1132,6 +1192,151 @@ async def get_agent_health() -> dict:
|
||||
return {"ok": True, "items": items, "counts": counts}
|
||||
|
||||
|
||||
# The escalation note escalate_issue() writes as an author_type='system' comment.
|
||||
# One prefix + a [severity] tag — parsed back here for the ops panel (#218/#222 UI).
|
||||
_ESCALATION_BODY_PREFIX = "🚨 הסלמה"
|
||||
_ESCALATION_SEVERITY_RE = re.compile(r"\[(critical|high|medium)\]")
|
||||
|
||||
|
||||
async def get_recent_escalations(limit: int = 10, hours: int = 24) -> dict:
|
||||
"""Recent chair escalations (watchdog + manual), newest-first (#218/#222 UI).
|
||||
|
||||
Read-only. Reconstructs escalations from the ``author_type='system'`` notes
|
||||
escalate_issue() leaves (``🚨 הסלמה [severity] לחיים\\n\\n<reason>``) — severity
|
||||
parsed from the tag, reason from the body, identifier joined from the issue.
|
||||
Scoped to the two legal-ai companies; ``limit`` clamped 1..50, ``hours`` 1..168.
|
||||
"""
|
||||
limit = max(1, min(int(limit), 50))
|
||||
hours = max(1, min(int(hours), 168))
|
||||
company_ids = list(COMPANIES.values())
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
f"""SELECT ic.issue_id, ic.body, ic.created_at, i.identifier
|
||||
FROM issue_comments ic
|
||||
JOIN issues i ON i.id = ic.issue_id
|
||||
WHERE ic.author_type = 'system'
|
||||
AND ic.company_id = ANY($1::uuid[])
|
||||
AND ic.body LIKE $2
|
||||
AND ic.deleted_at IS NULL
|
||||
AND ic.created_at > now() - interval '{hours} hours'
|
||||
ORDER BY ic.created_at DESC
|
||||
LIMIT $3""",
|
||||
company_ids, f"{_ESCALATION_BODY_PREFIX}%", limit,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
body = r["body"] or ""
|
||||
m = _ESCALATION_SEVERITY_RE.search(body)
|
||||
severity = m.group(1) if m else "medium"
|
||||
reason = body.split("\n\n", 1)[1].strip() if "\n\n" in body else body
|
||||
items.append({
|
||||
"issue_id": str(r["issue_id"]),
|
||||
"identifier": r["identifier"],
|
||||
"severity": severity,
|
||||
"reason": reason,
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
})
|
||||
return {"ok": True, "items": items}
|
||||
|
||||
|
||||
async def get_predecessor_context(issue_id: str, limit: int = 3) -> dict:
|
||||
"""Recent finished runs on an issue, newest-first, for session continuation (#220).
|
||||
|
||||
The "seance" idea from Gastown, grounded in our data: each ephemeral heartbeat
|
||||
rediscovers context every wake (the generic *blind heartbeat*; see
|
||||
[[project_comment_delivery_guarantee]]). This lets a fresh wake read what its
|
||||
predecessors on the SAME issue concluded — the run ``summary`` each heartbeat
|
||||
leaves in ``heartbeat_runs.result_json`` — instead of re-deriving from scratch.
|
||||
|
||||
Read-only. Runs are tied to the issue via ``wakeup_request_id`` →
|
||||
``agent_wakeup_requests.payload->>'issueId'`` (only finished runs are useful
|
||||
as predecessors). ``limit`` is clamped to 1..10.
|
||||
"""
|
||||
limit = max(1, min(int(limit), 10))
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT h.id, h.status, h.started_at, h.finished_at,
|
||||
h.result_json->>'summary' AS summary,
|
||||
h.error_code, h.session_id_after, a.name AS agent_name
|
||||
FROM heartbeat_runs h
|
||||
JOIN agent_wakeup_requests w ON w.id = h.wakeup_request_id
|
||||
LEFT JOIN agents a ON a.id = h.agent_id
|
||||
WHERE w.payload->>'issueId' = $1
|
||||
AND h.finished_at IS NOT NULL
|
||||
ORDER BY h.started_at DESC
|
||||
LIMIT $2""",
|
||||
issue_id, limit,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
runs = [_shape_predecessor_run(r) for r in rows]
|
||||
return {"ok": True, "issue_id": issue_id, "runs": runs}
|
||||
|
||||
|
||||
def _shape_predecessor_run(r) -> dict:
|
||||
"""Shared row → predecessor-run dict (by-issue and by-case entry points, G2)."""
|
||||
return {
|
||||
"run_id": str(r["id"]),
|
||||
"status": r["status"],
|
||||
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
||||
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
|
||||
"summary": r["summary"],
|
||||
"error_code": r["error_code"],
|
||||
"session_id": r["session_id_after"],
|
||||
"agent_name": r["agent_name"],
|
||||
}
|
||||
|
||||
|
||||
async def get_predecessor_for_case(case_number: str, limit: int = 5) -> dict:
|
||||
"""Recent finished runs across a CASE's issues, newest-first (#220, agent-facing).
|
||||
|
||||
The case-scoped sibling of :func:`get_predecessor_context`. The agent knows its
|
||||
``case_number`` (not the Paperclip issue UUID), so the plugin tool
|
||||
``legal_predecessor_context`` calls this. Resolves the case's Paperclip project,
|
||||
then returns finished heartbeat-run summaries across its issues — so a resuming
|
||||
wake reads what prior sessions on the case concluded instead of re-deriving.
|
||||
|
||||
Read-only. The issue join casts the known-valid ``issues.id`` to text (rather
|
||||
than the free-form ``payload->>'issueId'`` to uuid) so a malformed payload
|
||||
can't break the query. ``limit`` clamped 1..10.
|
||||
"""
|
||||
limit = max(1, min(int(limit), 10))
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
project = await conn.fetchrow(
|
||||
"SELECT id FROM projects WHERE name LIKE $1 LIMIT 1", f"%{case_number}%",
|
||||
)
|
||||
if not project:
|
||||
return {"ok": True, "case_number": case_number, "runs": []}
|
||||
rows = await conn.fetch(
|
||||
"""SELECT h.id, h.status, h.started_at, h.finished_at,
|
||||
h.result_json->>'summary' AS summary,
|
||||
h.error_code, h.session_id_after, a.name AS agent_name,
|
||||
i.identifier
|
||||
FROM heartbeat_runs h
|
||||
JOIN agent_wakeup_requests w ON w.id = h.wakeup_request_id
|
||||
JOIN issues i ON i.id::text = w.payload->>'issueId'
|
||||
LEFT JOIN agents a ON a.id = h.agent_id
|
||||
WHERE i.project_id = $1
|
||||
AND h.finished_at IS NOT NULL
|
||||
AND h.result_json->>'summary' IS NOT NULL
|
||||
ORDER BY h.started_at DESC
|
||||
LIMIT $2""",
|
||||
project["id"], limit,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
runs = [{**_shape_predecessor_run(r), "identifier": r["identifier"]} for r in rows]
|
||||
return {"ok": True, "case_number": case_number, "runs": runs}
|
||||
|
||||
|
||||
# Singleton project for the precedent-library extraction queue. One issue per
|
||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||
@@ -1554,6 +1759,93 @@ async def wake_ceo_for_action(
|
||||
}
|
||||
|
||||
|
||||
async def open_ceo_run(
|
||||
case_number: str,
|
||||
instruction: str,
|
||||
company_id: str = "",
|
||||
) -> dict:
|
||||
"""Open a FRESH CEO-owned run carrying a free-form chair instruction.
|
||||
|
||||
Drives the "פתח הרצה חדשה" option of the agents-tab target selector: instead
|
||||
of appending the instruction to an existing (possibly human-owned or closed)
|
||||
issue, a **child issue assigned to the CEO** is created under the case's main
|
||||
issue and the CEO is woken on it. This is the same CEO-child primitive as
|
||||
:func:`wake_ceo_for_action` (#227) — it sidesteps the ``issue_assignee_changed``
|
||||
cancellation that fires when an agent is woken on a human-owned parent (the
|
||||
exact silent failure the chair hit on 1027-04-26; see TaskMaster #228).
|
||||
|
||||
Wakeup goes through the Paperclip API, never a direct DB insert. Returns
|
||||
``{"status":"ok", "issue_id"(=child), "identifier", ...}`` or
|
||||
``{"status":"skipped", "reason": ...}``.
|
||||
"""
|
||||
if not PAPERCLIP_BOARD_API_KEY:
|
||||
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO new-run")
|
||||
return {"status": "skipped", "reason": "no_api_key"}
|
||||
|
||||
issues = await get_case_issues(case_number)
|
||||
if not issues:
|
||||
logger.warning("No Paperclip issues for case %s — skipping CEO new-run", case_number)
|
||||
return {"status": "skipped", "reason": "no_issue"}
|
||||
|
||||
# Parent the new run under the live main issue (never a closed child).
|
||||
main_issue = pick_default_comment_target(issues)
|
||||
main_issue_id = main_issue["id"]
|
||||
if not company_id:
|
||||
company_id = main_issue.get("company_id", "")
|
||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||
|
||||
title = f'[ערר {case_number}] הוראת יו"ר — הרצה חדשה'
|
||||
description = (
|
||||
'הוראת יו"ר שנשלחה כ"הרצה חדשה" מטאב הסוכנים. קרא את מצב התיק, טפל בהוראה '
|
||||
"בסגנון דפנה, ומסור disposition תקין בסיום.\n\n---\n\n" + instruction
|
||||
)
|
||||
child_resp = await pc_request(
|
||||
"POST",
|
||||
f"/api/issues/{main_issue_id}/children",
|
||||
json={
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "in_progress",
|
||||
"priority": "medium",
|
||||
"assigneeAgentId": ceo_id,
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
sub_issue = child_resp.json()
|
||||
sub_issue_id = sub_issue["id"]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||
finally:
|
||||
await conn.close()
|
||||
except Exception as e:
|
||||
logger.warning("plugin_state link failed for new-run sub_issue=%s: %s", sub_issue_id, e)
|
||||
|
||||
await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json={
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
"reason": f"chair_new_run_{case_number}",
|
||||
"payload": {"issueId": sub_issue_id, "case_number": case_number},
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
logger.info(
|
||||
"CEO new-run for case %s: child_issue=%s ceo=%s", case_number, sub_issue_id, ceo_id,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"issue_id": sub_issue_id,
|
||||
"identifier": sub_issue.get("identifier", ""),
|
||||
"main_issue_id": main_issue_id,
|
||||
"ceo_id": ceo_id,
|
||||
}
|
||||
|
||||
|
||||
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
||||
GENERATION_TITLE_SUBSTR = {
|
||||
"party_claims_summary": "סיכום-מנהלים",
|
||||
|
||||
@@ -19,7 +19,9 @@ from agent_health import ( # noqa: E402
|
||||
STALL_WAKEUP_THRESHOLD,
|
||||
classify_issue_health,
|
||||
is_recovery_reason,
|
||||
zombie_escalation,
|
||||
)
|
||||
from agent_health import ZOMBIE_ESCALATE_MIN_RECOVERY # noqa: E402
|
||||
|
||||
|
||||
# ── pure classifier ────────────────────────────────────────────────────────
|
||||
@@ -45,6 +47,34 @@ def test_quiet_open_issue_is_idle():
|
||||
assert classify_issue_health(has_live_run=False, recovery_wakeups=0, total_wakeups=1) == "idle"
|
||||
|
||||
|
||||
# ── watchdog escalation policy (pure) ───────────────────────────────────────
|
||||
def _item(health, recovery):
|
||||
return {"health": health, "recovery_wakeups": recovery, "identifier": "CMP-141", "agent_name": "writer"}
|
||||
|
||||
|
||||
def test_zombie_escalation_medium_for_fresh_loop():
|
||||
decision = zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY))
|
||||
assert decision is not None
|
||||
severity, reason = decision
|
||||
assert severity == "medium"
|
||||
assert "CMP-141" in reason and "recovery" in reason.lower()
|
||||
|
||||
|
||||
def test_zombie_escalation_high_for_long_loop():
|
||||
severity, _ = zombie_escalation(_item("zombie", 5))
|
||||
assert severity == "high"
|
||||
|
||||
|
||||
def test_zombie_below_threshold_is_not_escalated():
|
||||
assert zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY - 1)) is None
|
||||
|
||||
|
||||
def test_non_zombie_never_escalates():
|
||||
assert zombie_escalation(_item("stalled", 9)) is None
|
||||
assert zombie_escalation(_item("idle", 0)) is None
|
||||
assert zombie_escalation(_item("working", 9)) is None
|
||||
|
||||
|
||||
def test_is_recovery_reason_markers():
|
||||
assert is_recovery_reason("source_scoped_recovery_action")
|
||||
assert is_recovery_reason("issue_continuation_needed")
|
||||
@@ -113,3 +143,109 @@ def test_get_agent_health_empty(monkeypatch):
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_agent_health())
|
||||
assert result == {"ok": True, "items": [], "counts": {"zombie": 0, "stalled": 0, "working": 0, "idle": 0}}
|
||||
|
||||
|
||||
# ── recent escalations (fake DB) — parses severity + reason from system notes ──
|
||||
class _FetchConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
async def fetch(self, query, *args):
|
||||
return self._rows
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_get_recent_escalations_parses_severity_and_reason(monkeypatch):
|
||||
from datetime import datetime
|
||||
|
||||
rows = [
|
||||
{
|
||||
"issue_id": "iss-1",
|
||||
"identifier": "CMP-141",
|
||||
"body": "🚨 הסלמה [high] לחיים\n\nזוהתה לולאת-recovery — 4 יקיצות-שחזור; נמסר לחיים.",
|
||||
"created_at": datetime(2026, 7, 7, 3, 30, 0),
|
||||
},
|
||||
{
|
||||
"issue_id": "iss-2",
|
||||
"identifier": "CMPA-112",
|
||||
"body": "🚨 הסלמה [medium] לחיים\n\nזוהתה לולאת-recovery — 2 יקיצות.",
|
||||
"created_at": datetime(2026, 7, 7, 2, 49, 0),
|
||||
},
|
||||
]
|
||||
|
||||
async def _connect(_url):
|
||||
return _FetchConn(rows)
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_recent_escalations())
|
||||
assert result["ok"] is True
|
||||
a, b = result["items"]
|
||||
assert a["identifier"] == "CMP-141" and a["severity"] == "high"
|
||||
assert "4 יקיצות" in a["reason"] and "🚨" not in a["reason"]
|
||||
assert a["created_at"] == "2026-07-07T03:30:00"
|
||||
assert b["severity"] == "medium"
|
||||
|
||||
|
||||
def test_get_recent_escalations_defaults_severity_when_untagged(monkeypatch):
|
||||
rows = [{"issue_id": "x", "identifier": "CMP-9", "body": "🚨 הסלמה לחיים", "created_at": None}]
|
||||
|
||||
async def _connect(_url):
|
||||
return _FetchConn(rows)
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
(item,) = asyncio.run(pc.get_recent_escalations())["items"]
|
||||
assert item["severity"] == "medium" # fallback
|
||||
assert item["created_at"] is None
|
||||
|
||||
|
||||
# ── predecessor by case (#220 agent-facing) — resolve project then shape runs ──
|
||||
class _ProjectRunsConn:
|
||||
def __init__(self, project, runs):
|
||||
self._project, self._runs = project, runs
|
||||
|
||||
async def fetchrow(self, query, *args):
|
||||
return self._project
|
||||
|
||||
async def fetch(self, query, *args):
|
||||
return self._runs
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_get_predecessor_for_case_shapes_runs(monkeypatch):
|
||||
from datetime import datetime
|
||||
|
||||
runs = [
|
||||
{
|
||||
"id": "run-1", "status": "succeeded",
|
||||
"started_at": datetime(2026, 7, 5, 10, 0, 0),
|
||||
"finished_at": datetime(2026, 7, 5, 10, 5, 0),
|
||||
"summary": "הלולאה נסגרה כראוי", "error_code": None,
|
||||
"session_id_after": "sess-9", "agent_name": "עוזר משפטי",
|
||||
"identifier": "CMP-141",
|
||||
},
|
||||
]
|
||||
|
||||
async def _connect(_url):
|
||||
return _ProjectRunsConn({"id": "proj-1"}, runs)
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_predecessor_for_case("1043-02-26"))
|
||||
assert result["ok"] is True and result["case_number"] == "1043-02-26"
|
||||
(run,) = result["runs"]
|
||||
assert run["run_id"] == "run-1"
|
||||
assert run["identifier"] == "CMP-141" # by-case adds which issue
|
||||
assert run["started_at"] == "2026-07-05T10:00:00"
|
||||
assert "הלולאה" in run["summary"]
|
||||
|
||||
|
||||
def test_get_predecessor_for_case_unknown_case_is_empty(monkeypatch):
|
||||
async def _connect(_url):
|
||||
return _ProjectRunsConn(None, []) # no project row
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
result = asyncio.run(pc.get_predecessor_for_case("9999-99-99"))
|
||||
assert result == {"ok": True, "case_number": "9999-99-99", "runs": []}
|
||||
|
||||
78
web/tests/test_comment_target.py
Normal file
78
web/tests/test_comment_target.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests for pick_default_comment_target — chair-comment routing.
|
||||
|
||||
Regression guard for the recurring bug where a chair instruction typed into the
|
||||
"כתוב הוראה לסוכנים..." box was routed to a *cancelled* child issue, so
|
||||
Paperclip skipped the wakeup and the instruction was silently swallowed.
|
||||
|
||||
The picker must never default to a closed (done/cancelled) issue and must prefer
|
||||
the live top-level CEO issue. Pure function — no DB, no FastAPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
|
||||
|
||||
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
|
||||
|
||||
|
||||
def _iss(id_, status, parent_id=None):
|
||||
return {"id": id_, "status": status, "parent_id": parent_id, "identifier": id_}
|
||||
|
||||
|
||||
def test_prefers_open_top_level_over_newer_cancelled_child():
|
||||
"""The exact 1027-04-26 scenario: newest non-done issue is a cancelled child;
|
||||
routing must still land on the live top-level CEO issue."""
|
||||
issues = [
|
||||
_iss("main-old", "done"), # oldest top-level, closed
|
||||
_iss("main-live", "in_review"), # live CEO main issue
|
||||
_iss("child-cancelled", "cancelled", "main-live"), # newest non-done → the trap
|
||||
_iss("child-done", "done", "main-live"),
|
||||
]
|
||||
assert pc.pick_default_comment_target(issues)["id"] == "main-live"
|
||||
|
||||
|
||||
def test_never_returns_cancelled_when_an_open_issue_exists():
|
||||
issues = [
|
||||
_iss("a", "done"),
|
||||
_iss("b", "in_progress", "a"),
|
||||
_iss("c", "cancelled", "a"),
|
||||
]
|
||||
assert pc.pick_default_comment_target(issues)["status"] != "cancelled"
|
||||
|
||||
|
||||
def test_falls_back_to_open_child_when_no_open_top_level():
|
||||
issues = [
|
||||
_iss("main", "done"),
|
||||
_iss("child-live", "in_progress", "main"),
|
||||
]
|
||||
assert pc.pick_default_comment_target(issues)["id"] == "child-live"
|
||||
|
||||
|
||||
def test_prefers_top_level_when_everything_closed():
|
||||
"""No open issue anywhere — pick the newest top-level (better than a child)."""
|
||||
issues = [
|
||||
_iss("main-1", "done"),
|
||||
_iss("main-2", "cancelled"),
|
||||
_iss("child", "done", "main-2"),
|
||||
]
|
||||
assert pc.pick_default_comment_target(issues)["id"] == "main-2"
|
||||
|
||||
|
||||
def test_single_issue():
|
||||
issues = [_iss("only", "cancelled")]
|
||||
assert pc.pick_default_comment_target(issues)["id"] == "only"
|
||||
|
||||
|
||||
def test_picks_newest_open_top_level():
|
||||
issues = [
|
||||
_iss("main-1", "in_review"),
|
||||
_iss("main-2", "in_review"),
|
||||
]
|
||||
assert pc.pick_default_comment_target(issues)["id"] == "main-2"
|
||||
84
web/tests/test_predecessor_context.py
Normal file
84
web/tests/test_predecessor_context.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for #220 — paperclip_client.get_predecessor_context ("seance").
|
||||
|
||||
Fakes an asyncpg connection so it never touches the live DB. Verifies the
|
||||
limit clamp, row shaping (timestamps → isoformat), and the empty case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
|
||||
|
||||
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
async def fetch(self, query, *args):
|
||||
self.calls.append((query, args))
|
||||
return self._rows
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _row():
|
||||
return {
|
||||
"id": "run-1",
|
||||
"status": "succeeded",
|
||||
"started_at": datetime(2026, 7, 5, 10, 0, 0),
|
||||
"finished_at": datetime(2026, 7, 5, 10, 5, 0),
|
||||
"summary": "כתבתי בלוק ז — 3,206 מילים",
|
||||
"error_code": None,
|
||||
"session_id_after": "sess-abc",
|
||||
"agent_name": "writer",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(monkeypatch):
|
||||
holder = {"rows": [_row()], "conn": None}
|
||||
|
||||
async def _connect(_url):
|
||||
holder["conn"] = _FakeConn(holder["rows"])
|
||||
return holder["conn"]
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
return holder
|
||||
|
||||
|
||||
def test_shapes_runs_newest_first(fake):
|
||||
result = asyncio.run(pc.get_predecessor_context("iss-1"))
|
||||
assert result["ok"] is True and result["issue_id"] == "iss-1"
|
||||
(run,) = result["runs"]
|
||||
assert run["run_id"] == "run-1"
|
||||
assert run["status"] == "succeeded"
|
||||
assert run["started_at"] == "2026-07-05T10:00:00"
|
||||
assert run["finished_at"] == "2026-07-05T10:05:00"
|
||||
assert "בלוק ז" in run["summary"]
|
||||
assert run["session_id"] == "sess-abc"
|
||||
assert run["agent_name"] == "writer"
|
||||
|
||||
|
||||
def test_limit_is_clamped(fake):
|
||||
asyncio.run(pc.get_predecessor_context("iss-1", limit=99))
|
||||
assert fake["conn"].calls[0][1] == ("iss-1", 10) # clamped high
|
||||
asyncio.run(pc.get_predecessor_context("iss-1", limit=0))
|
||||
assert fake["conn"].calls[0][1] == ("iss-1", 1) # clamped low
|
||||
|
||||
|
||||
def test_empty(fake):
|
||||
fake["rows"] = []
|
||||
result = asyncio.run(pc.get_predecessor_context("ghost"))
|
||||
assert result == {"ok": True, "issue_id": "ghost", "runs": []}
|
||||
Reference in New Issue
Block a user