Add "Start Workflow" button to trigger CEO agent from web UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 49s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 49s
New endpoint POST /api/cases/{case_number}/start-workflow creates a
Paperclip issue, wakes the CEO agent via wakeup API, and transitions
case status to "processing". Button appears on case page only when
status is "new" or "documents_ready".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@ import { DocumentsPanel } from "@/components/cases/documents-panel";
|
|||||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||||
import { useCase } from "@/lib/api/cases";
|
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Play, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||||
@@ -33,6 +35,8 @@ export default function CaseDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const { caseNumber } = use(params);
|
const { caseNumber } = use(params);
|
||||||
const { data, isPending, error } = useCase(caseNumber);
|
const { data, isPending, error } = useCase(caseNumber);
|
||||||
|
const startWorkflow = useStartWorkflow(caseNumber);
|
||||||
|
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||||
const expectedOutcomeLabel = data?.expected_outcome
|
const expectedOutcomeLabel = data?.expected_outcome
|
||||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||||
: null;
|
: null;
|
||||||
@@ -107,6 +111,29 @@ export default function CaseDetailPage({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap pt-2 border-t border-rule">
|
<div className="flex items-center gap-3 flex-wrap pt-2 border-t border-rule">
|
||||||
|
{canStartWorkflow && (
|
||||||
|
<Button
|
||||||
|
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||||
|
disabled={startWorkflow.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
startWorkflow.mutate(undefined, {
|
||||||
|
onSuccess: (res) =>
|
||||||
|
toast.success(
|
||||||
|
`תהליך הופעל — ${res.issue_identifier}`,
|
||||||
|
),
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(`שגיאה: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{startWorkflow.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-4 h-4 me-1.5" />
|
||||||
|
)}
|
||||||
|
התחל תהליך
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
פתח בעורך ההחלטה
|
פתח בעורך ההחלטה
|
||||||
|
|||||||
@@ -167,6 +167,32 @@ export function useGitStatus(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StartWorkflowResult = {
|
||||||
|
case_number: string;
|
||||||
|
status: string;
|
||||||
|
issue_id: string;
|
||||||
|
issue_identifier: string;
|
||||||
|
project_url: string;
|
||||||
|
wakeup: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useStartWorkflow(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<StartWorkflowResult>(
|
||||||
|
`/api/cases/${caseNumber}/start-workflow`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useWorkflowStatus(caseNumber: string | undefined) {
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||||
|
|||||||
67
web/app.py
67
web/app.py
@@ -35,7 +35,12 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl
|
|||||||
_web_dir = Path(__file__).resolve().parent
|
_web_dir = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(_web_dir.parent))
|
sys.path.insert(0, str(_web_dir.parent))
|
||||||
from web.gitea_client import create_repo, setup_remote_and_push
|
from web.gitea_client import create_repo, setup_remote_and_push
|
||||||
from web.paperclip_client import create_project as pc_create_project, get_project_url
|
from web.paperclip_client import (
|
||||||
|
create_project as pc_create_project,
|
||||||
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
|
get_project_url,
|
||||||
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1129,7 +1134,16 @@ async def api_case_git_status(case_number: str):
|
|||||||
if not git_dir.exists():
|
if not git_dir.exists():
|
||||||
return {"synced": False, "error": "no_repo"}
|
return {"synced": False, "error": "no_repo"}
|
||||||
|
|
||||||
env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "GIT_TERMINAL_PROMPT": "0"}
|
env = {
|
||||||
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||||
|
"HOME": os.environ.get("HOME", "/root"),
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_CONFIG_GLOBAL": "/dev/null",
|
||||||
|
}
|
||||||
|
# Ensure git trusts the case directory regardless of ownership
|
||||||
|
env["GIT_CONFIG_COUNT"] = "1"
|
||||||
|
env["GIT_CONFIG_KEY_0"] = "safe.directory"
|
||||||
|
env["GIT_CONFIG_VALUE_0"] = str(case_dir)
|
||||||
|
|
||||||
# Last commit info
|
# Last commit info
|
||||||
log = subprocess.run(
|
log = subprocess.run(
|
||||||
@@ -2160,6 +2174,55 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
|
|||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/start-workflow")
|
||||||
|
async def api_start_workflow(case_number: str):
|
||||||
|
"""Start the CEO agent workflow for a case.
|
||||||
|
|
||||||
|
Creates a workflow issue in Paperclip and wakes the CEO agent.
|
||||||
|
Only works when case status is 'new' or 'documents_ready'.
|
||||||
|
"""
|
||||||
|
# 1. Verify case exists and status is appropriate
|
||||||
|
case_raw = await cases_tools.case_get(case_number)
|
||||||
|
case_data = json.loads(case_raw)
|
||||||
|
if "error" in case_data:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
status = case_data.get("status", "")
|
||||||
|
allowed = {"new", "documents_ready"}
|
||||||
|
if status not in allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"לא ניתן להתחיל תהליך — סטטוס נוכחי: {status}. נדרש: {', '.join(allowed)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Create workflow issue in Paperclip
|
||||||
|
try:
|
||||||
|
issue = await pc_create_workflow_issue(case_number, case_data.get("title", ""))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
# 3. Wake the CEO agent
|
||||||
|
try:
|
||||||
|
wakeup = await pc_wake_ceo(issue["issue_id"], case_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO wakeup failed for case %s: %s", case_number, e)
|
||||||
|
wakeup = {"error": str(e)}
|
||||||
|
|
||||||
|
# 4. Update case status to processing
|
||||||
|
await cases_tools.case_update(case_number, status="processing")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "processing",
|
||||||
|
"issue_id": issue["issue_id"],
|
||||||
|
"issue_identifier": issue["identifier"],
|
||||||
|
"project_url": issue["project_url"],
|
||||||
|
"wakeup": wakeup,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Settings: Tag → Company Mappings ──────────────────────────────
|
# ── Settings: Tag → Company Mappings ──────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/settings/paperclip-companies")
|
@app.get("/api/settings/paperclip-companies")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ PAPERCLIP_DB_URL = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
||||||
|
CEO_AGENT_ID = "752cebdd-6748-4a04-aacd-c7ab0294ef33"
|
||||||
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||||
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||||
|
|
||||||
# Company IDs from Paperclip DB
|
# Company IDs from Paperclip DB
|
||||||
COMPANIES = {
|
COMPANIES = {
|
||||||
@@ -251,3 +255,80 @@ async def get_project_url(case_number: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_workflow_issue(case_number: str, title: str) -> dict:
|
||||||
|
"""Create a workflow issue in the existing Paperclip project for a case.
|
||||||
|
|
||||||
|
Returns dict with issue_id, identifier, project_url.
|
||||||
|
Raises ValueError if no project found.
|
||||||
|
"""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
# Find existing project
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, company_id FROM projects WHERE name LIKE $1",
|
||||||
|
f"%{case_number}%",
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"No Paperclip project found for case {case_number}")
|
||||||
|
|
||||||
|
project_id = str(row["id"])
|
||||||
|
company_id = str(row["company_id"])
|
||||||
|
|
||||||
|
# Get company prefix
|
||||||
|
comp_row = await conn.fetchrow(
|
||||||
|
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
|
||||||
|
)
|
||||||
|
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||||
|
|
||||||
|
# Create the workflow issue
|
||||||
|
issue_id, identifier = await _create_issue(
|
||||||
|
conn, company_id, project_id, case_number,
|
||||||
|
f"התחל תהליך ניסוח — {title}"[:200], prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link to legal-ai case via plugin state
|
||||||
|
await _link_case_to_issue(conn, issue_id, case_number)
|
||||||
|
|
||||||
|
project_url = f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{project_id}/issues"
|
||||||
|
logger.info("Created workflow issue %s for case %s", identifier, case_number)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"identifier": identifier,
|
||||||
|
"project_url": project_url,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_ceo_agent(issue_id: str, case_number: str) -> dict:
|
||||||
|
"""Wake the CEO agent via Paperclip's wakeup API.
|
||||||
|
|
||||||
|
MUST use API, never direct DB insert (agent won't wake from DB insert).
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
||||||
|
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{CEO_AGENT_ID}/wakeup"
|
||||||
|
payload = {
|
||||||
|
"source": "web-ui",
|
||||||
|
"triggerDetail": "user",
|
||||||
|
"reason": f"start_workflow_{case_number}",
|
||||||
|
"payload": {
|
||||||
|
"issueId": issue_id,
|
||||||
|
"mutation": "workflow_start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||||
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user