All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
Paperclip heartbeat staleness gate (heartbeat.js evaluateQueuedRunStaleness) cancels queued runs when issue.assigneeAgentId !== run.agentId, with error "issue assignee changed before the queued run could start". Older Paperclip versions auto-assigned on wakeup; the current version does not, so issues created with NULL assignee silently never run. Set assignee_agent_id to the company's CEO at INSERT time. Affects both the project setup issue and the "התחל תהליך ניסוח" workflow issue.
666 lines
26 KiB
Python
666 lines
26 KiB
Python
"""Paperclip integration via direct DB access (embedded PostgreSQL).
|
|
|
|
Creates projects, issues, and plugin state entries to fully link
|
|
a legal-ai case into Paperclip's workflow.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
|
|
import asyncpg
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PAPERCLIP_DB_URL = os.environ.get(
|
|
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
|
)
|
|
|
|
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
|
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
|
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
|
|
|
# Default workspace attached to every new Paperclip project — points agents at
|
|
# the legal-ai source tree on the host. Override via env if the path differs.
|
|
LEGAL_AI_WORKSPACE_CWD = os.environ.get("LEGAL_AI_WORKSPACE_CWD", "/home/chaim/legal-ai")
|
|
LEGAL_AI_WORKSPACE_NAME = os.environ.get("LEGAL_AI_WORKSPACE_NAME", "legal-ai")
|
|
|
|
# Company IDs from Paperclip DB
|
|
COMPANIES = {
|
|
"licensing": "42a7acd0-30c5-4cbd-ac97-7424f65df294", # CMP — רישוי ובניה
|
|
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה
|
|
}
|
|
|
|
# CEO agent per company — used for wakeup routing
|
|
CEO_AGENTS = {
|
|
COMPANIES["licensing"]: "752cebdd-6748-4a04-aacd-c7ab0294ef33", # CMP CEO
|
|
COMPANIES["betterment"]: "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", # CMPA CEO
|
|
}
|
|
# Default for backwards compat
|
|
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
|
|
|
# Fallback mapping — used only when DB lookup returns no results
|
|
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
|
"רישוי": COMPANIES["licensing"],
|
|
"היטל השבחה": COMPANIES["betterment"],
|
|
"פיצויים": COMPANIES["betterment"],
|
|
"building_permit": COMPANIES["licensing"],
|
|
"betterment_levy": COMPANIES["betterment"],
|
|
"compensation_197": COMPANIES["betterment"],
|
|
"compensation": COMPANIES["betterment"],
|
|
"licensing": COMPANIES["licensing"],
|
|
}
|
|
|
|
# Legal-AI DB URL for reading tag_company_mappings
|
|
_LEGAL_DB_URL = os.environ.get("POSTGRES_URL") or os.environ.get(
|
|
"DATABASE_URL", "postgresql://legal:legal@127.0.0.1:5432/legal_ai"
|
|
)
|
|
|
|
|
|
async def _get_company_id(appeal_type: str) -> str:
|
|
"""Resolve appeal_type tag to a Paperclip company ID via DB mappings, with fallback."""
|
|
try:
|
|
conn = await asyncpg.connect(_LEGAL_DB_URL)
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"SELECT company_id FROM tag_company_mappings WHERE tag = $1 LIMIT 1",
|
|
appeal_type,
|
|
)
|
|
if row:
|
|
return row["company_id"]
|
|
finally:
|
|
await conn.close()
|
|
except Exception:
|
|
logger.debug("DB lookup for tag mapping failed, using fallback for '%s'", appeal_type)
|
|
|
|
return _FALLBACK_APPEAL_TYPE_TO_COMPANY.get(appeal_type, COMPANIES["licensing"])
|
|
|
|
|
|
async def create_project(
|
|
case_number: str,
|
|
title: str,
|
|
description: str = "",
|
|
appeal_type: str = "רישוי",
|
|
color: str = "#6366f1",
|
|
) -> dict:
|
|
"""Create a project in the Paperclip embedded DB, or return existing one."""
|
|
company_id = await _get_company_id(appeal_type)
|
|
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
# Resolve prefix from company issue_prefix in Paperclip DB
|
|
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"
|
|
|
|
# Check for existing project with this case number
|
|
existing = await conn.fetchrow(
|
|
"SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid",
|
|
f"%{case_number}%", company_id,
|
|
)
|
|
if existing:
|
|
# Backfill: ensure legacy projects also have a default workspace.
|
|
await _ensure_default_workspace(conn, str(existing["id"]), company_id)
|
|
return {
|
|
"id": str(existing["id"]),
|
|
"company_id": company_id,
|
|
"name": existing["name"],
|
|
"url": f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{existing['id']}/issues",
|
|
"existing": True,
|
|
}
|
|
|
|
project_id = str(uuid.uuid4())
|
|
project_name = f"ערר {case_number} — {title}"[:200]
|
|
await conn.execute(
|
|
"""INSERT INTO projects (id, company_id, name, description, status, color)
|
|
VALUES ($1, $2::uuid, $3, $4, 'backlog', $5)""",
|
|
project_id, company_id, project_name, description[:500] if description else "", color,
|
|
)
|
|
# Default primary workspace — points agents at the legal-ai source tree.
|
|
await _ensure_default_workspace(conn, project_id, company_id)
|
|
# Create initial issue linked to the project
|
|
issue_id, identifier = await _create_issue(
|
|
conn, company_id, project_id, case_number, title, prefix,
|
|
)
|
|
|
|
# Link issue to legal-ai case via plugin state
|
|
await _link_case_to_issue(conn, issue_id, case_number)
|
|
|
|
# Verify project creation and close the setup issue
|
|
await _verify_and_close_setup_issue(conn, project_id, issue_id, identifier, case_number)
|
|
|
|
return {
|
|
"id": project_id,
|
|
"company_id": company_id,
|
|
"name": project_name,
|
|
"issue_id": issue_id,
|
|
"issue_identifier": identifier,
|
|
"url": f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{project_id}/issues",
|
|
"existing": False,
|
|
}
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def archive_project(case_number: str) -> dict:
|
|
"""Set archived_at on the Paperclip project matching this case number,
|
|
and cancel any open issues so the legal-ai UI's agent widget stops
|
|
reporting "agents are working" on a closed case.
|
|
|
|
The project is identified by `name LIKE '%{case_number}%'` (consistent with
|
|
`create_project`'s lookup). Idempotent — re-archiving a project that's
|
|
already archived returns the existing timestamp without re-cancelling
|
|
issues that have already been completed.
|
|
"""
|
|
# Issue statuses considered "open" — anything not done/cancelled.
|
|
OPEN_STATUSES = ("backlog", "todo", "in_progress", "blocked", "in_review")
|
|
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""UPDATE projects
|
|
SET archived_at = COALESCE(archived_at, now()),
|
|
updated_at = now()
|
|
WHERE name LIKE $1 RETURNING id, name, archived_at""",
|
|
f"%{case_number}%",
|
|
)
|
|
if not row:
|
|
return {"status": "not_found", "case_number": case_number}
|
|
|
|
cancelled = await conn.fetch(
|
|
"""UPDATE issues
|
|
SET status = 'cancelled',
|
|
cancelled_at = now(),
|
|
updated_at = now()
|
|
WHERE project_id = $1 AND status = ANY($2::text[])
|
|
RETURNING identifier, title""",
|
|
row["id"], list(OPEN_STATUSES),
|
|
)
|
|
|
|
return {
|
|
"status": "archived",
|
|
"project_id": str(row["id"]),
|
|
"name": row["name"],
|
|
"archived_at": row["archived_at"].isoformat() if row["archived_at"] else None,
|
|
"issues_cancelled": [
|
|
{"identifier": r["identifier"], "title": r["title"]}
|
|
for r in cancelled
|
|
],
|
|
}
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def restore_project(case_number: str) -> dict:
|
|
"""Clear archived_at on the Paperclip project matching this case number.
|
|
|
|
Idempotent — if already active, returns success without changes.
|
|
"""
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""UPDATE projects
|
|
SET archived_at = NULL,
|
|
updated_at = now()
|
|
WHERE name LIKE $1 RETURNING id, name""",
|
|
f"%{case_number}%",
|
|
)
|
|
if not row:
|
|
return {"status": "not_found", "case_number": case_number}
|
|
return {
|
|
"status": "restored",
|
|
"project_id": str(row["id"]),
|
|
"name": row["name"],
|
|
}
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def _ensure_default_workspace(
|
|
conn: asyncpg.Connection,
|
|
project_id: str,
|
|
company_id: str,
|
|
) -> None:
|
|
"""Idempotently attach a primary workspace to the project so the
|
|
"סביבות עבודה" tab appears in the Paperclip UI and agents wake up with
|
|
cwd=`/home/chaim/legal-ai`. No-op if any workspace already exists.
|
|
"""
|
|
existing = await conn.fetchval(
|
|
"SELECT id FROM project_workspaces WHERE project_id = $1::uuid LIMIT 1",
|
|
project_id,
|
|
)
|
|
if existing:
|
|
return
|
|
|
|
await conn.execute(
|
|
"""INSERT INTO project_workspaces
|
|
(company_id, project_id, name, cwd, is_primary, source_type, visibility)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, TRUE, 'local_path', 'default')""",
|
|
company_id, project_id, LEGAL_AI_WORKSPACE_NAME, LEGAL_AI_WORKSPACE_CWD,
|
|
)
|
|
logger.info(
|
|
"Attached default workspace (cwd=%s) to project %s",
|
|
LEGAL_AI_WORKSPACE_CWD, project_id,
|
|
)
|
|
|
|
|
|
async def _create_issue(
|
|
conn: asyncpg.Connection,
|
|
company_id: str,
|
|
project_id: str,
|
|
case_number: str,
|
|
title: str,
|
|
prefix: str,
|
|
) -> tuple[str, str]:
|
|
"""Create an issue in the project and return (issue_id, identifier)."""
|
|
issue_id = str(uuid.uuid4())
|
|
|
|
# Get next issue number for this company
|
|
row = await conn.fetchrow(
|
|
"UPDATE companies SET issue_counter = issue_counter + 1 WHERE id = $1::uuid RETURNING issue_counter",
|
|
company_id,
|
|
)
|
|
issue_number = row["issue_counter"]
|
|
identifier = f"{prefix}-{issue_number}"
|
|
|
|
# Assign to the company's CEO so Paperclip's wakeup gate
|
|
# (heartbeat staleness check) accepts the wakeup. Without this,
|
|
# the run is cancelled with "issue assignee changed before the
|
|
# queued run could start" and the agent never starts.
|
|
ceo_agent_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
|
|
|
await conn.execute(
|
|
"""INSERT INTO issues (id, company_id, project_id, title, description, status, priority, issue_number, identifier, assignee_agent_id)
|
|
VALUES ($1, $2::uuid, $3::uuid, $4, $5, 'todo', 'medium', $6, $7, $8::uuid)""",
|
|
issue_id, company_id, project_id,
|
|
f"[ערר {case_number}] {title}"[:200],
|
|
f"תיק ערר {case_number}\nנוצר אוטומטית מממשק העלאת מסמכים",
|
|
issue_number, identifier, ceo_agent_id,
|
|
)
|
|
|
|
logger.info("Created Paperclip issue %s: [ערר %s] %s", identifier, case_number, title)
|
|
return issue_id, identifier
|
|
|
|
|
|
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(
|
|
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
|
|
VALUES ($1::uuid, 'issue', $2, 'default', 'legal-case-number', $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(case_number),
|
|
)
|
|
logger.info("Linked issue %s to case %s via plugin state", issue_id, case_number)
|
|
|
|
|
|
async def _verify_and_close_setup_issue(
|
|
conn: asyncpg.Connection,
|
|
project_id: str,
|
|
issue_id: str,
|
|
identifier: str,
|
|
case_number: str,
|
|
) -> None:
|
|
"""Verify the project was created correctly, then transition the setup issue to done."""
|
|
# Move to in_progress while verifying
|
|
await conn.execute(
|
|
"UPDATE issues SET status = 'in_progress', started_at = now() WHERE id = $1",
|
|
issue_id,
|
|
)
|
|
logger.info("%s: בביצוע — מאמת יצירת פרויקט", identifier)
|
|
|
|
# Verify: project exists, issue is linked, plugin state exists
|
|
checks = []
|
|
|
|
project = await conn.fetchrow("SELECT id, name FROM projects WHERE id = $1::uuid", project_id)
|
|
checks.append(("פרויקט נוצר", project is not None))
|
|
|
|
issue = await conn.fetchrow(
|
|
"SELECT id, project_id FROM issues WHERE id = $1 AND project_id = $2::uuid",
|
|
issue_id, project_id,
|
|
)
|
|
checks.append(("משימה משויכת לפרויקט", issue is not None))
|
|
|
|
plugin_link = await conn.fetchrow(
|
|
"SELECT value_json FROM plugin_state WHERE scope_id = $1 AND state_key = 'legal-case-number'",
|
|
issue_id,
|
|
)
|
|
checks.append(("קישור למערכת המשפטית", plugin_link is not None))
|
|
|
|
all_ok = all(ok for _, ok in checks)
|
|
report_lines = [f"{'✓' if ok else '✕'} {name}" for name, ok in checks]
|
|
report = "\n".join(report_lines)
|
|
|
|
if all_ok:
|
|
await conn.execute(
|
|
"UPDATE issues SET status = 'done', completed_at = now() WHERE id = $1",
|
|
issue_id,
|
|
)
|
|
# Document the verification in a comment
|
|
await conn.execute(
|
|
"""INSERT INTO issue_comments (id, company_id, issue_id, body)
|
|
VALUES ($1, (SELECT company_id FROM issues WHERE id = $2), $2,
|
|
$3)""",
|
|
str(uuid.uuid4()), issue_id,
|
|
f"## אימות יצירת פרויקט — ערר {case_number}\n\n{report}\n\nהפרויקט נוצר בהצלחה. משימה נסגרה אוטומטית.",
|
|
)
|
|
logger.info("%s: הושלם — פרויקט אומת ונסגר", identifier)
|
|
else:
|
|
# Leave in_progress with a warning comment
|
|
failed = [name for name, ok in checks if not ok]
|
|
await conn.execute(
|
|
"""INSERT INTO issue_comments (id, company_id, issue_id, body)
|
|
VALUES ($1, (SELECT company_id FROM issues WHERE id = $2), $2,
|
|
$3)""",
|
|
str(uuid.uuid4()), issue_id,
|
|
f"## אימות יצירת פרויקט — ערר {case_number}\n\n{report}\n\n⚠️ בדיקות שנכשלו: {', '.join(failed)}",
|
|
)
|
|
logger.warning("%s: אימות נכשל — %s", identifier, ", ".join(failed))
|
|
|
|
|
|
async def get_project_url(case_number: str) -> str | None:
|
|
"""Find existing Paperclip project for a case number."""
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, company_id FROM projects WHERE name LIKE $1",
|
|
f"%{case_number}%",
|
|
)
|
|
if row:
|
|
comp_row = await conn.fetchrow(
|
|
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", str(row["company_id"]),
|
|
)
|
|
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
|
return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues"
|
|
return None
|
|
finally:
|
|
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,
|
|
"company_id": company_id,
|
|
"project_url": project_url,
|
|
}
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def get_case_issues(case_number: str) -> list[dict]:
|
|
"""Get all Paperclip issues linked to a legal-ai case number.
|
|
|
|
Matches via two paths to avoid missing historical issues:
|
|
(a) the original setup linkage in plugin_state (state_key = legal-case-number)
|
|
(b) issues whose title contains "[ערר {case_number}]" or "ערר {case_number}"
|
|
— that's how sub-agents conventionally tag follow-up issues.
|
|
Returns the union of both, deduplicated by issue id, ordered by creation time.
|
|
"""
|
|
title_patterns = [f"%[ערר {case_number}]%", f"%ערר {case_number}%"]
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""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
|
|
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
|
|
AND ps.plugin_id = $1::uuid
|
|
AND ps.state_key = 'legal-case-number'
|
|
AND ps.value_json = $2::jsonb
|
|
WHERE ps.scope_id IS NOT NULL
|
|
OR i.title LIKE ANY($3::text[])
|
|
ORDER BY i.id, i.created_at""",
|
|
PLUGIN_ID, json.dumps(case_number), title_patterns,
|
|
)
|
|
# Sort by created_at after dedup
|
|
sorted_rows = sorted(rows, key=lambda r: r["created_at"])
|
|
return [
|
|
{
|
|
"id": str(r["id"]),
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"identifier": r["identifier"],
|
|
"priority": r["priority"],
|
|
"assignee_name": r["assignee_name"],
|
|
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
|
"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"]),
|
|
}
|
|
for r in sorted_rows
|
|
]
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
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:
|
|
return []
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""SELECT ic.id, ic.issue_id, ic.body, ic.created_at,
|
|
ic.author_agent_id, ic.author_user_id,
|
|
a.name AS agent_name, a.role AS agent_role, a.icon AS agent_icon
|
|
FROM issue_comments ic
|
|
LEFT JOIN agents a ON ic.author_agent_id = a.id
|
|
WHERE ic.issue_id = ANY($1::uuid[])
|
|
ORDER BY ic.created_at""",
|
|
issue_ids,
|
|
)
|
|
return [
|
|
{
|
|
"id": str(r["id"]),
|
|
"issue_id": str(r["issue_id"]),
|
|
"body": r["body"],
|
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
|
"author_agent_id": str(r["author_agent_id"]) if r["author_agent_id"] else None,
|
|
"author_user_id": r["author_user_id"],
|
|
"agent_name": r["agent_name"],
|
|
"agent_role": r["agent_role"],
|
|
"agent_icon": r["agent_icon"],
|
|
}
|
|
for r in rows
|
|
]
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def get_agents_for_company(company_id: str) -> list[dict]:
|
|
"""Get all agents belonging to a Paperclip company."""
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""SELECT id, name, role, title, status, icon, last_heartbeat_at
|
|
FROM agents
|
|
WHERE company_id = $1::uuid
|
|
ORDER BY role, name""",
|
|
company_id,
|
|
)
|
|
return [
|
|
{
|
|
"id": str(r["id"]),
|
|
"name": r["name"],
|
|
"role": r["role"],
|
|
"title": r["title"],
|
|
"status": r["status"],
|
|
"icon": r["icon"],
|
|
"last_heartbeat_at": r["last_heartbeat_at"].isoformat() if r["last_heartbeat_at"] else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dict]:
|
|
"""Get agents with per-case status (running on *this* case vs globally)."""
|
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
|
try:
|
|
rows = await conn.fetch(
|
|
"""SELECT a.id, a.name, a.role, a.title, a.icon,
|
|
a.status AS global_status, a.last_heartbeat_at,
|
|
EXISTS(
|
|
SELECT 1 FROM heartbeat_runs hr
|
|
JOIN agent_wakeup_requests wr ON hr.wakeup_request_id = wr.id
|
|
WHERE hr.agent_id = a.id
|
|
AND hr.status = 'running'
|
|
AND wr.payload->>'issueId' = ANY($2::text[])
|
|
) AS active_on_case
|
|
FROM agents a
|
|
WHERE a.company_id = $1::uuid
|
|
ORDER BY a.role, a.name""",
|
|
company_id, issue_ids,
|
|
)
|
|
return [
|
|
{
|
|
"id": str(r["id"]),
|
|
"name": r["name"],
|
|
"role": r["role"],
|
|
"title": r["title"],
|
|
"status": "running" if r["active_on_case"] else (
|
|
"idle" if r["global_status"] == "running" else r["global_status"]
|
|
),
|
|
"icon": r["icon"],
|
|
"last_heartbeat_at": r["last_heartbeat_at"].isoformat() if r["last_heartbeat_at"] else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|
"""Post a comment on a Paperclip issue.
|
|
|
|
Tries the Board API first (triggers plugin events for CEO routing).
|
|
Falls back to direct DB insert + CEO wakeup if API fails.
|
|
"""
|
|
# Try Board API first — this triggers the event bus
|
|
if PAPERCLIP_BOARD_API_KEY:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
|
|
json={"body": body},
|
|
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
|
)
|
|
if resp.status_code < 400:
|
|
result = resp.json()
|
|
logger.info("Posted comment via Board API on issue %s", issue_id)
|
|
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
|
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:
|
|
await conn.execute(
|
|
"""INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body)
|
|
VALUES ($1, $2::uuid, $3::uuid, 'chaim', $4)""",
|
|
comment_id, company_id, issue_id, body,
|
|
)
|
|
logger.info("Posted comment via DB fallback on issue %s", issue_id)
|
|
finally:
|
|
await conn.close()
|
|
|
|
# Wake the correct CEO for this company
|
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
|
try:
|
|
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
payload = {
|
|
"source": "on_demand",
|
|
"triggerDetail": "manual",
|
|
"reason": f"user_comment_{issue_id}",
|
|
"payload": {"issueId": issue_id, "mutation": "comment"},
|
|
}
|
|
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()
|
|
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"}
|
|
|
|
|
|
async def wake_ceo_agent(issue_id: str, case_number: str, company_id: 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).
|
|
Routes to the correct CEO based on company_id.
|
|
"""
|
|
if not PAPERCLIP_BOARD_API_KEY:
|
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
|
|
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
|
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
|
payload = {
|
|
"source": "on_demand",
|
|
"triggerDetail": "manual",
|
|
"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
|