Merge pull request 'feat(generate): סימון-סטטוס נשמר-שרת לריצת-הרקע של "הפקת מסמכים" (#227 ג)' (#399) from worktree-generate-status-indicator into main
This commit was merged in pull request #399.
This commit is contained in:
@@ -31,7 +31,9 @@ import {
|
|||||||
usePartyClaimsSummary,
|
usePartyClaimsSummary,
|
||||||
useGeneratePartyClaimsSummary,
|
useGeneratePartyClaimsSummary,
|
||||||
useGenerateInterimDraft,
|
useGenerateInterimDraft,
|
||||||
|
useGenerateStatus,
|
||||||
} from "@/lib/api/generate";
|
} from "@/lib/api/generate";
|
||||||
|
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
|
||||||
import {
|
import {
|
||||||
useCaseFeedback,
|
useCaseFeedback,
|
||||||
useCreateFeedback,
|
useCreateFeedback,
|
||||||
@@ -133,6 +135,9 @@ export function DraftsPanel({
|
|||||||
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
||||||
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
||||||
const genInterim = useGenerateInterimDraft(caseNumber);
|
const genInterim = useGenerateInterimDraft(caseNumber);
|
||||||
|
// Server-derived background-run status (#227 ג) — survives navigation/reload.
|
||||||
|
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
|
||||||
|
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -342,6 +347,11 @@ export function DraftsPanel({
|
|||||||
"הפק"
|
"הפק"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<GenerateStatusChip
|
||||||
|
status={summaryStatus.data}
|
||||||
|
onRetry={handleGenerateSummary}
|
||||||
|
retrying={genSummary.isPending}
|
||||||
|
/>
|
||||||
{summaryReady && (
|
{summaryReady && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -410,6 +420,11 @@ export function DraftsPanel({
|
|||||||
"הפק"
|
"הפק"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<GenerateStatusChip
|
||||||
|
status={interimStatus.data}
|
||||||
|
onRetry={handleGenerateInterim}
|
||||||
|
retrying={genInterim.isPending}
|
||||||
|
/>
|
||||||
{latestInterim && (
|
{latestInterim && (
|
||||||
<span className="text-[0.7rem] text-ink-muted">
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
{latestInterim.version
|
{latestInterim.version
|
||||||
|
|||||||
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
|
||||||
|
import type { GenerateStatus } from "@/lib/api/generate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
|
||||||
|
*
|
||||||
|
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
|
||||||
|
* holds no run state of its own, so it renders correctly after the chair
|
||||||
|
* navigates away and back. The running timer is anchored to the server
|
||||||
|
* `started_at`, not a click-time counter, so the elapsed value is right on
|
||||||
|
* return too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active]);
|
||||||
|
if (!startedAt) return null;
|
||||||
|
const start = Date.parse(startedAt);
|
||||||
|
if (Number.isNaN(start)) return null;
|
||||||
|
return Math.max(0, Math.floor((now - start) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (Number.isNaN(t)) return "";
|
||||||
|
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||||
|
if (sec < 60) return "הרגע";
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `לפני ${min} דק׳`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `לפני ${hr} שע׳`;
|
||||||
|
return `לפני ${Math.floor(hr / 24)} ימים`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIP_BASE =
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
|
||||||
|
|
||||||
|
export function GenerateStatusChip({
|
||||||
|
status,
|
||||||
|
onRetry,
|
||||||
|
retrying,
|
||||||
|
}: {
|
||||||
|
status: GenerateStatus | undefined;
|
||||||
|
onRetry?: () => void;
|
||||||
|
retrying?: boolean;
|
||||||
|
}) {
|
||||||
|
const state = status?.state ?? "idle";
|
||||||
|
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
|
||||||
|
|
||||||
|
if (!status || state === "idle") return null;
|
||||||
|
|
||||||
|
if (state === "queued") {
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
|
||||||
|
<Clock className="size-3.5" aria-hidden />
|
||||||
|
בתור…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "running") {
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
רץ ברקע
|
||||||
|
{elapsed !== null && (
|
||||||
|
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "done") {
|
||||||
|
const rel = relativeTime(status.finished_at);
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
|
||||||
|
<CheckCircle2 className="size-3.5" aria-hidden />
|
||||||
|
הושלם{rel && ` · ${rel}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
|
||||||
|
<XCircle className="size-3.5" aria-hidden />
|
||||||
|
{status.detail || "נכשל"}
|
||||||
|
</span>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-gold-deep"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin me-1" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="size-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
נסה שוב
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,12 +29,58 @@ export type GenerateTriggerResult = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The two host-side background generation actions (poll target of the status chip). */
|
||||||
|
export type GenerateAction = "party_claims_summary" | "interim_draft";
|
||||||
|
|
||||||
export const generateKeys = {
|
export const generateKeys = {
|
||||||
all: ["generate"] as const,
|
all: ["generate"] as const,
|
||||||
partyClaimsSummary: (caseNumber: string) =>
|
partyClaimsSummary: (caseNumber: string) =>
|
||||||
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
|
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
|
||||||
|
status: (caseNumber: string, action: GenerateAction) =>
|
||||||
|
[...generateKeys.all, "status", action, caseNumber] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-derived background-run status (#227 ג). Reconstructed on the backend
|
||||||
|
* from the CEO child issue + its heartbeat_run + the output artifact — so it is
|
||||||
|
* correct after navigating away and back; it never depends on the browser having
|
||||||
|
* stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps,
|
||||||
|
* so the client renders a correct elapsed time on return.
|
||||||
|
*/
|
||||||
|
export type GenerateState = "idle" | "queued" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export type GenerateStatus = {
|
||||||
|
case_number: string;
|
||||||
|
action: GenerateAction;
|
||||||
|
state: GenerateState;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
output_ready: boolean;
|
||||||
|
detail: string;
|
||||||
|
issue_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGenerateStatus(
|
||||||
|
caseNumber: string | undefined,
|
||||||
|
action: GenerateAction,
|
||||||
|
) {
|
||||||
|
return useQuery<GenerateStatus>({
|
||||||
|
queryKey: generateKeys.status(caseNumber ?? "", action),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<GenerateStatus>(
|
||||||
|
`/api/cases/${caseNumber}/generate/${action}/status`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
// Poll only while a run is in flight; a terminal state stops the interval.
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const s = query.state.data?.state;
|
||||||
|
return s === "running" || s === "queued" ? 5_000 : false;
|
||||||
|
},
|
||||||
|
staleTime: 2_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Read: party-claims executive summary (poll target for button 1) ──
|
/* ── Read: party-claims executive summary (poll target for button 1) ──
|
||||||
404 (not generated yet) is a normal "not ready" state, not an error — we
|
404 (not generated yet) is a normal "not ready" state, not an error — we
|
||||||
coerce it to null so the panel renders the empty/idle state cleanly. */
|
coerce it to null so the panel renders the empty/idle state cleanly. */
|
||||||
@@ -70,23 +116,33 @@ export function useGeneratePartyClaimsSummary(caseNumber: string) {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Start polling fresh — the file will appear after the host run completes.
|
// Start polling fresh — the file will appear after the host run completes,
|
||||||
|
// and the status chip should pick up the new queued/running run.
|
||||||
qc.invalidateQueries({
|
qc.invalidateQueries({
|
||||||
queryKey: generateKeys.partyClaimsSummary(caseNumber),
|
queryKey: generateKeys.partyClaimsSummary(caseNumber),
|
||||||
});
|
});
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
|
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
|
||||||
export function useGenerateInterimDraft(caseNumber: string) {
|
export function useGenerateInterimDraft(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiRequest<GenerateTriggerResult>(
|
apiRequest<GenerateTriggerResult>(
|
||||||
`/api/cases/${caseNumber}/generate/interim-draft`,
|
`/api/cases/${caseNumber}/generate/interim-draft`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
),
|
),
|
||||||
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports
|
// The result lands in the exports list (טיוטת-ביניים-*.docx, polled by
|
||||||
// already polls on a 5s interval, so no extra invalidation is needed here.
|
// useExports); re-arm the status chip so it shows the new run immediately.
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.status(caseNumber, "interim_draft"),
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ from web.paperclip_client import (
|
|||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
||||||
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
||||||
wake_analyst_for_protocol_analysis as pc_wake_analyst_for_protocol_analysis,
|
wake_analyst_for_protocol_analysis as pc_wake_analyst_for_protocol_analysis,
|
||||||
|
get_generation_run_status as pc_get_generation_run_status,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
wake_ceo_for_action as pc_wake_ceo_for_action,
|
wake_ceo_for_action as pc_wake_ceo_for_action,
|
||||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||||
@@ -108,6 +109,7 @@ __all__ = [
|
|||||||
"pc_wake_analyst_for_appraiser_facts",
|
"pc_wake_analyst_for_appraiser_facts",
|
||||||
"pc_wake_analyst_for_argument_aggregation",
|
"pc_wake_analyst_for_argument_aggregation",
|
||||||
"pc_wake_analyst_for_protocol_analysis",
|
"pc_wake_analyst_for_protocol_analysis",
|
||||||
|
"pc_get_generation_run_status",
|
||||||
# comments / interactions
|
# comments / interactions
|
||||||
"pc_post_comment",
|
"pc_post_comment",
|
||||||
"pc_get_issue_comments",
|
"pc_get_issue_comments",
|
||||||
|
|||||||
90
web/app.py
90
web/app.py
@@ -80,6 +80,7 @@ from web.agent_platform_port import (
|
|||||||
pc_wake_analyst_for_appraiser_facts,
|
pc_wake_analyst_for_appraiser_facts,
|
||||||
pc_wake_analyst_for_argument_aggregation,
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
pc_wake_analyst_for_protocol_analysis,
|
pc_wake_analyst_for_protocol_analysis,
|
||||||
|
pc_get_generation_run_status,
|
||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
pc_wake_ceo_for_action,
|
pc_wake_ceo_for_action,
|
||||||
@@ -3249,6 +3250,95 @@ async def api_generate_interim_draft(case_number: str):
|
|||||||
return await _wake_ceo_action(case_number, "interim_draft")
|
return await _wake_ceo_action(case_number, "interim_draft")
|
||||||
|
|
||||||
|
|
||||||
|
_GENERATE_ACTIONS = {"party_claims_summary", "interim_draft"}
|
||||||
|
# Run statuses (Paperclip heartbeat_runs.status) grouped for state mapping.
|
||||||
|
_RUN_ACTIVE = {"running", "in_progress", "queued", "pending", "scheduled", "created"}
|
||||||
|
_RUN_FAILED = {"cancelled", "failed", "error", "timed_out", "lost"}
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_output_ready(case_number: str, action: str) -> bool:
|
||||||
|
"""Whether the generation artifact already exists on disk (host-side)."""
|
||||||
|
if action == "party_claims_summary":
|
||||||
|
from legal_mcp.services import party_claims_summary as pcs
|
||||||
|
return pcs.summary_file_path(case_number).exists()
|
||||||
|
# interim_draft → a "טיוטת-ביניים-{case}-*.docx" file in the case exports dir.
|
||||||
|
export_dir = config.find_case_dir(case_number) / "exports"
|
||||||
|
if not export_dir.exists():
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
f.is_file() and f.suffix.lower() == ".docx" and f.name.startswith("טיוטת-ביניים-")
|
||||||
|
for f in export_dir.iterdir()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/generate/{action}/status")
|
||||||
|
async def api_generate_status(case_number: str, action: str):
|
||||||
|
"""Server-derived status of a "הפקת מסמכים" background generation (#227 ג).
|
||||||
|
|
||||||
|
Reconstructs {idle|queued|running|done|failed} from the CEO child issue + its
|
||||||
|
latest heartbeat_run (via the platform port) plus whether the output artifact
|
||||||
|
exists — so the UI indicator is correct after navigating away and back; it
|
||||||
|
never depends on the browser staying on the page. ``started_at``/``finished_at``
|
||||||
|
are server timestamps (ISO-8601 UTC) so the client renders a correct elapsed
|
||||||
|
time on return.
|
||||||
|
"""
|
||||||
|
if action not in _GENERATE_ACTIONS:
|
||||||
|
raise HTTPException(400, f"action לא תקין: {action}")
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
output_ready = _generation_output_ready(case_number, action)
|
||||||
|
try:
|
||||||
|
run = await pc_get_generation_run_status(case_number, action)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("generation status lookup failed for %s/%s: %s", case_number, action, e)
|
||||||
|
run = {"issue_id": None}
|
||||||
|
|
||||||
|
run_status = (run.get("run_status") or "").lower()
|
||||||
|
started_at = run.get("started_at")
|
||||||
|
active = run_status in _RUN_ACTIVE or (
|
||||||
|
run.get("issue_status") == "in_progress"
|
||||||
|
and run_status not in _RUN_FAILED
|
||||||
|
and run_status not in {"completed", "succeeded", "done"}
|
||||||
|
and not run.get("finished_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precedence: an in-flight run wins; then a produced artifact; then a failed
|
||||||
|
# run; else idle. (A produced file reads as "done" even if a later retry
|
||||||
|
# failed — the artifact exists.)
|
||||||
|
if active:
|
||||||
|
state = "running" if started_at else "queued"
|
||||||
|
elif output_ready:
|
||||||
|
state = "done"
|
||||||
|
elif run_status in _RUN_FAILED:
|
||||||
|
state = "failed"
|
||||||
|
elif run_status in {"completed", "succeeded", "done"}:
|
||||||
|
# Ran to completion but produced no artifact → treat as a failure so the
|
||||||
|
# chair isn't left thinking it worked.
|
||||||
|
state = "failed"
|
||||||
|
else:
|
||||||
|
state = "idle"
|
||||||
|
|
||||||
|
detail = ""
|
||||||
|
if state == "failed":
|
||||||
|
if run.get("error_code") == "issue_assignee_changed":
|
||||||
|
detail = "הריצה בוטלה (הוקצתה מחדש)"
|
||||||
|
else:
|
||||||
|
detail = run.get("error") or "הריצה נכשלה"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"action": action,
|
||||||
|
"state": state,
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": run.get("finished_at"),
|
||||||
|
"output_ready": output_ready,
|
||||||
|
"detail": detail,
|
||||||
|
"issue_id": run.get("issue_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
||||||
async def api_party_claims_summary_download(case_number: str):
|
async def api_party_claims_summary_download(case_number: str):
|
||||||
"""Download the raw party-claims-summary.md file."""
|
"""Download the raw party-claims-summary.md file."""
|
||||||
|
|||||||
@@ -1384,6 +1384,73 @@ async def wake_ceo_for_action(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
||||||
|
GENERATION_TITLE_SUBSTR = {
|
||||||
|
"party_claims_summary": "סיכום-מנהלים",
|
||||||
|
"interim_draft": "טיוטת טענות-הצדדים",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_generation_run_status(case_number: str, action: str) -> dict:
|
||||||
|
"""Server-derived run status for a "הפקת מסמכים" generation action (#227 ג).
|
||||||
|
|
||||||
|
Reconstructs the state of the most recent generation child issue for
|
||||||
|
(case, action) from Paperclip — the child issue + its latest heartbeat_run —
|
||||||
|
so the UI indicator survives navigation/reload (it never depends on the
|
||||||
|
browser having stayed on the page). Read-only Paperclip DB access, behind the
|
||||||
|
platform port (G12).
|
||||||
|
|
||||||
|
Returns a serializable dict::
|
||||||
|
|
||||||
|
{"issue_id", "issue_status", "run_status", "started_at", "finished_at",
|
||||||
|
"error", "error_code"}
|
||||||
|
|
||||||
|
with all timestamps as ISO-8601 UTC strings (or None). ``issue_id`` is None
|
||||||
|
when no generation child issue exists yet (→ the endpoint reports 'idle').
|
||||||
|
"""
|
||||||
|
substr = GENERATION_TITLE_SUBSTR.get(action)
|
||||||
|
if not substr:
|
||||||
|
return {"issue_id": None}
|
||||||
|
|
||||||
|
def _iso(dt) -> str | None:
|
||||||
|
return dt.isoformat() if dt is not None else None
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
issue = await conn.fetchrow(
|
||||||
|
"""SELECT id, status
|
||||||
|
FROM issues
|
||||||
|
WHERE title LIKE ('%' || $1 || '%') AND title LIKE ('%' || $2 || '%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1""",
|
||||||
|
case_number, substr,
|
||||||
|
)
|
||||||
|
if issue is None:
|
||||||
|
return {"issue_id": None}
|
||||||
|
|
||||||
|
run = await conn.fetchrow(
|
||||||
|
"""SELECT hr.status, hr.started_at, hr.finished_at, hr.error, hr.error_code
|
||||||
|
FROM heartbeat_runs hr
|
||||||
|
JOIN agent_wakeup_requests awr ON hr.wakeup_request_id = awr.id
|
||||||
|
WHERE awr.payload->>'issueId' = $1
|
||||||
|
ORDER BY hr.created_at DESC
|
||||||
|
LIMIT 1""",
|
||||||
|
str(issue["id"]),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_id": str(issue["id"]),
|
||||||
|
"issue_status": issue["status"],
|
||||||
|
"run_status": run["status"] if run else None,
|
||||||
|
"started_at": _iso(run["started_at"]) if run else None,
|
||||||
|
"finished_at": _iso(run["finished_at"]) if run else None,
|
||||||
|
"error": (run["error"] if run else None) or None,
|
||||||
|
"error_code": (run["error_code"] if run else None) or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
|
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
|
||||||
"""Build the (sub-issue title, description) for a staged final-decision task.
|
"""Build the (sub-issue title, description) for a staged final-decision task.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user