feat(agents-panel): היררכיה, סמן-כהושלם ידני, ובורר-יעד נגלל (5 תיקוני יו"ר)
טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב
(Claude Design X17, כרטיס 18i4):
1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת
בתג "↳ <parent>" גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון parent_id
כבר הגיע מה-API — תצוגה בלבד.
2. סמן-כהושלם / בטל ידני — כל משימה פתוחה מקבלת פעולות בכותרת. פותר
"אין אפשרות לסמן ידנית" (superseded runs נתקעו ב-in_review בלי מנגנון
סגירה). endpoint חדש: POST /api/cases/{n}/agents/issue-status → דרך
ה-Port (pc_set_issue_status), סגירה loop-safe ישירה ל-DB, בלי wakeup.
3. בורר-היעד נגלל — max-height + overflow פנימי, כך שהתיבה לא בורחת
מגבולות העמוד. פותר "לא רואים את התחתית / לא מגיעים לפתיחת הליך".
4. "פתח הרצה חדשה" עלה לראש הרשימה, מעל "משימות פעילות", ומודגש.
5. קו-הפרדה בין משימות בציר-הזמן עבה יותר (border-b-2 rule).
G12: כל מגע-פלטפורמה עובר דרך agent_platform_port. אימות: lint + tsc +
next build עוברים.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
useSendComment,
|
useSendComment,
|
||||||
useSubmitInteraction,
|
useSubmitInteraction,
|
||||||
useDismissInteraction,
|
useDismissInteraction,
|
||||||
|
useSetIssueStatus,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import type {
|
import type {
|
||||||
Interaction,
|
Interaction,
|
||||||
@@ -772,31 +773,96 @@ function IssueGroup({
|
|||||||
defaultOpen: boolean;
|
defaultOpen: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const setStatus = useSetIssueStatus(caseNumber);
|
||||||
const closed = issue.status === "done" || issue.status === "cancelled";
|
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||||
|
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
|
||||||
|
// the live CEO root) so the hierarchy is visible, not flattened.
|
||||||
|
const parentIdentifier = issue.parent_id
|
||||||
|
? issueMap.get(issue.parent_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const close = (status: "done" | "cancelled") =>
|
||||||
|
setStatus.mutate(
|
||||||
|
{ issue_id: issue.id, status },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast.success(
|
||||||
|
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
|
||||||
|
),
|
||||||
|
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-rule-soft last:border-b-0">
|
<div className="border-b-2 border-rule last:border-b-0">
|
||||||
<button
|
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
type="button"
|
||||||
aria-expanded={open}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
aria-expanded={open}
|
||||||
>
|
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||||
<Badge
|
|
||||||
variant={closed ? "secondary" : "default"}
|
|
||||||
className="text-[10px] font-mono shrink-0"
|
|
||||||
>
|
>
|
||||||
{issue.identifier}
|
<Badge
|
||||||
</Badge>
|
variant={closed ? "secondary" : "default"}
|
||||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
className="text-[10px] font-mono shrink-0"
|
||||||
{shortIssueTitle(issue.title)}
|
>
|
||||||
</span>
|
{issue.identifier}
|
||||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
</Badge>
|
||||||
{issueStatusLabel(issue.status)} · {items.length}
|
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||||
</span>
|
{shortIssueTitle(issue.title)}
|
||||||
<ChevronDown
|
</span>
|
||||||
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
{parentIdentifier && (
|
||||||
/>
|
<span
|
||||||
</button>
|
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parentIdentifier}`}
|
||||||
|
>
|
||||||
|
↳ {parentIdentifier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||||
|
{issueStatusLabel(issue.status)} · {items.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!closed && (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => close("done")}
|
||||||
|
disabled={setStatus.isPending}
|
||||||
|
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
|
||||||
|
title="סמן את המשימה כהושלמה"
|
||||||
|
>
|
||||||
|
{setStatus.isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
הושלם
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => close("cancelled")}
|
||||||
|
disabled={setStatus.isPending}
|
||||||
|
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
|
||||||
|
title="בטל את המשימה"
|
||||||
|
>
|
||||||
|
בטל
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-label={open ? "כווץ" : "הרחב"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="pb-1">
|
<div className="pb-1">
|
||||||
{items.map((item) =>
|
{items.map((item) =>
|
||||||
@@ -878,6 +944,13 @@ function TargetSelector({
|
|||||||
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||||
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||||
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||||
|
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
|
||||||
|
const idToIdentifier = useMemo(
|
||||||
|
() => new Map(issues.map((i) => [i.id, i.identifier])),
|
||||||
|
[issues],
|
||||||
|
);
|
||||||
|
const parentTag = (i: PaperclipIssue) =>
|
||||||
|
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
|
||||||
|
|
||||||
// The issue the pill currently represents (explicit pick, or the auto default).
|
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||||
const effectiveIssue =
|
const effectiveIssue =
|
||||||
@@ -925,7 +998,31 @@ function TargetSelector({
|
|||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
aria-hidden
|
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="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||||
|
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
|
||||||
|
visible without scrolling to the bottom of a long task list. */}
|
||||||
|
<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 bg-gold/10 border border-rule hover:border-gold/60"
|
||||||
|
>
|
||||||
|
<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 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 className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||||
משימות פעילות
|
משימות פעילות
|
||||||
</div>
|
</div>
|
||||||
@@ -933,6 +1030,7 @@ function TargetSelector({
|
|||||||
const selected =
|
const selected =
|
||||||
(target.kind === "issue" && target.id === i.id) ||
|
(target.kind === "issue" && target.id === i.id) ||
|
||||||
(target.kind === "auto" && defaultIssue?.id === i.id);
|
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||||
|
const parent = parentTag(i);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i.id}
|
key={i.id}
|
||||||
@@ -941,12 +1039,20 @@ function TargetSelector({
|
|||||||
onChange({ kind: "issue", id: i.id });
|
onChange({ kind: "issue", id: i.id });
|
||||||
setOpen(false);
|
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" : ""}`}
|
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
<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">
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
{i.identifier}
|
{i.identifier}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{parent && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parent}`}
|
||||||
|
>
|
||||||
|
↳ {parent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-ink-soft truncate flex-1">
|
<span className="text-xs text-ink-soft truncate flex-1">
|
||||||
{shortIssueTitle(i.title)}
|
{shortIssueTitle(i.title)}
|
||||||
</span>
|
</span>
|
||||||
@@ -968,50 +1074,39 @@ function TargetSelector({
|
|||||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||||
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||||
</div>
|
</div>
|
||||||
{closedIssues.map((i) => (
|
{closedIssues.map((i) => {
|
||||||
<div
|
const parent = parentTag(i);
|
||||||
key={i.id}
|
return (
|
||||||
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
|
<div
|
||||||
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
key={i.id}
|
||||||
>
|
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||||
<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 className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||||
</span>
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
</div>
|
{i.identifier}
|
||||||
))}
|
</Badge>
|
||||||
|
{parent && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parent}`}
|
||||||
|
>
|
||||||
|
↳ {parent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -208,6 +208,25 @@ export function useDismissInteraction(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Manually close a board issue to done/cancelled — the chair tidying the agents
|
||||||
|
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
|
||||||
|
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
|
||||||
|
export function useSetIssueStatus(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
|
||||||
|
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
|
||||||
|
`/api/cases/${caseNumber}/agents/issue-status`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentResetResult = {
|
export type AgentResetResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
reassigned_issues: { id: string; identifier: string }[];
|
reassigned_issues: { id: string; identifier: string }[];
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ from web.paperclip_client import (
|
|||||||
reap_stale_interactions as _reap_stale_interactions,
|
reap_stale_interactions as _reap_stale_interactions,
|
||||||
reset_agent_session as _reset_agent_session,
|
reset_agent_session as _reset_agent_session,
|
||||||
reset_case_agents as _reset_case_agents,
|
reset_case_agents as _reset_case_agents,
|
||||||
|
set_issue_status as _set_issue_status,
|
||||||
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
|
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
|
||||||
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
|
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
|
||||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||||
@@ -127,6 +128,12 @@ pc_cancel_interaction = instrument(
|
|||||||
pc_escalate_issue = instrument(
|
pc_escalate_issue = instrument(
|
||||||
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
|
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
|
||||||
)(_escalate_issue)
|
)(_escalate_issue)
|
||||||
|
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
|
||||||
|
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
|
||||||
|
# same telemetry stream as the escalations/wakeups it sits beside.
|
||||||
|
pc_set_issue_status = instrument(
|
||||||
|
"issue.status_set", keys=("issue_id", "status", "company_id"),
|
||||||
|
)(_set_issue_status)
|
||||||
pc_reap_stale_interactions = instrument(
|
pc_reap_stale_interactions = instrument(
|
||||||
"interaction.reaped", result_keys=("cancelled",),
|
"interaction.reaped", result_keys=("cancelled",),
|
||||||
)(_reap_stale_interactions)
|
)(_reap_stale_interactions)
|
||||||
@@ -192,6 +199,7 @@ __all__ = [
|
|||||||
"pc_cancel_interaction",
|
"pc_cancel_interaction",
|
||||||
"pc_reap_stale_interactions",
|
"pc_reap_stale_interactions",
|
||||||
"pc_escalate_issue",
|
"pc_escalate_issue",
|
||||||
|
"pc_set_issue_status",
|
||||||
# agent-run observability + control (live view + smart management)
|
# agent-run observability + control (live view + smart management)
|
||||||
"pc_get_agent_health",
|
"pc_get_agent_health",
|
||||||
"pc_get_recent_escalations",
|
"pc_get_recent_escalations",
|
||||||
|
|||||||
24
web/app.py
24
web/app.py
@@ -82,6 +82,7 @@ from web.agent_platform_port import (
|
|||||||
pc_reset_agent_session,
|
pc_reset_agent_session,
|
||||||
pc_reset_case_agents,
|
pc_reset_case_agents,
|
||||||
pc_respond_to_interaction,
|
pc_respond_to_interaction,
|
||||||
|
pc_set_issue_status,
|
||||||
pc_restore_project,
|
pc_restore_project,
|
||||||
pc_wake_analyst_for_appraiser_facts,
|
pc_wake_analyst_for_appraiser_facts,
|
||||||
pc_wake_analyst_for_argument_aggregation,
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
@@ -4851,6 +4852,29 @@ async def api_escalate_issue(case_number: str, req: EscalateRequest):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class IssueStatusRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
status: Literal["done", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/issue-status")
|
||||||
|
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
|
||||||
|
"""Manually close a board issue to done/cancelled from the chair UI.
|
||||||
|
|
||||||
|
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
|
||||||
|
left in ``in_review`` had no manual close control and piled up. Only the two
|
||||||
|
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
|
||||||
|
Port (``pc_set_issue_status``) and issues no wakeup.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
result = await pc_set_issue_status(req.issue_id, req.status)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||||
#
|
#
|
||||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||||
|
|||||||
@@ -988,6 +988,70 @@ async def escalate_issue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_issue_status(
|
||||||
|
issue_id: str, status: str, company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Manually close a case issue to ``done`` or ``cancelled`` from the chair UI.
|
||||||
|
|
||||||
|
The chair needs to tidy the agents board by hand: superseded "הרצה חדשה" runs
|
||||||
|
that were replaced by a later run pile up in ``in_review`` with no way to close
|
||||||
|
them — there was no manual control, so they had to be closed out-of-band. This
|
||||||
|
is the loop-safe primitive behind the board's "סמן כהושלם / בטל" actions.
|
||||||
|
|
||||||
|
Only the two **terminal** statuses are accepted (``CLOSED_ISSUE_STATUSES``) —
|
||||||
|
this tidies the board, it does not drive the workflow (agents own the
|
||||||
|
todo/in_progress/in_review transitions). Direct-DB in one transaction,
|
||||||
|
mirroring :func:`escalate_issue`: bypasses Paperclip's disposition resolver
|
||||||
|
(whose multi-PATCH/``issue.released`` behaviour *causes* the recovery loops),
|
||||||
|
sets the matching close timestamp, clears any agent assignee so no recovery
|
||||||
|
sweep re-acts on it, and records an ``author_type='system'`` audit note (inert
|
||||||
|
w.r.t. the user-comment routing sweep, so it will not wake an agent). No wakeup.
|
||||||
|
"""
|
||||||
|
if status not in CLOSED_ISSUE_STATUSES:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"invalid status {status!r}; expected one of {tuple(CLOSED_ISSUE_STATUSES)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# status is validated against the frozenset above, so the interpolated column
|
||||||
|
# name is one of exactly two literals — no injection surface.
|
||||||
|
ts_col = "completed_at" if status == "done" else "cancelled_at"
|
||||||
|
note = (
|
||||||
|
"✓ סומן ידנית כהושלם ע\"י היו\"ר"
|
||||||
|
if status == "done"
|
||||||
|
else "✕ בוטל ידנית ע\"י היו\"ר"
|
||||||
|
)
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""UPDATE issues
|
||||||
|
SET status=$1, {ts_col}=now(),
|
||||||
|
assignee_agent_id=null, updated_at=now()
|
||||||
|
WHERE id=$2::uuid
|
||||||
|
RETURNING id, identifier, company_id""",
|
||||||
|
status, issue_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return {"ok": False, "error": f"issue {issue_id} not found"}
|
||||||
|
cid = company_id or str(row["company_id"])
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO issue_comments (id, company_id, issue_id, body, author_type)
|
||||||
|
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, 'system')""",
|
||||||
|
str(uuid.uuid4()), cid, issue_id, note,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
logger.info("Chair set issue %s → %s", issue_id, status)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"identifier": row["identifier"],
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def respond_to_interaction(
|
async def respond_to_interaction(
|
||||||
issue_id: str, interaction_id: str, payload: dict,
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
Reference in New Issue
Block a user