All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
Wires the preview popover's Confirm & post action to ConnectWise (and,
via the provider pattern, any future PSA). Adds the parallel Escalate
flow with the handoff-oriented five-section markdown. Sessions without a
linked PSA ticket resolve/escalate locally — markdown stored, status
flipped, nothing posted externally.
Backend:
- EscalationPackageGeneratorService: Sonnet, five sections (Problem /
What we've confirmed / What we've tried / Current hypothesis /
Suggested next steps). Shares the preview_cache with a separate KIND
so Resolve and Escalate previews for the same state coexist.
- PSAWritebackService: post_resolution_note (RESOLUTION note type,
customer-visible), post_escalation_package (INTERNAL_ANALYSIS,
handoff for the next engineer only), transition_ticket_status with
mandatory re-fetch verification. PSAStatusVerificationError surfaces
loudly when CW silently rejects a status change — the
ConnectWise anti-pattern CLAUDE.md flags.
- Endpoints:
* POST /ai-sessions/{id}/escalation-package/preview
* POST /ai-sessions/{id}/resolution-note/post
* POST /ai-sessions/{id}/escalation-package/post
Outcomes: "resolved" / "escalated" with external_id + verified status,
"resolved_local" / "escalated_local" when no PSA linked.
- Target CW status IDs live in account_settings.preferences
(cw_resolved_status_id, cw_escalated_status_id). When unset, the post
proceeds without a status transition — response includes a
status_transition_skipped_reason rather than silently erroring.
- 7 tests: local-only path, PSA happy path with verified transition,
status verification failure → 502, skipped transition when
unconfigured, 409 on already-resolved re-post, escalate parallel path,
internal-analysis note type enforced.
Frontend:
- ResolutionNotePreview now kind-parameterized ('resolve' | 'escalate')
with inline edit + Confirm & post. Preview loads from the matching
backend endpoint; posting calls the matching endpoint; outcome toast
surfaces the verified CW status or the local-only result.
- AssistantChatPage: previewKind state replaces previewOpen; two toggle
buttons (Preview Resolve note / Escalate instead) in the lane's bottom
slot. handleConfirmPost dispatches by kind.
Verified 2026-04-22:
- Local-only Resolve + Escalate round-trip against the dev stack.
- Live Sonnet escalation-package preview; cache hit on repeat call
with no state change (separate cache kind from resolution-note).
- PSA post + status-verification paths covered by mocked-provider pytest
cases. Live CW round-trip pending a test CW instance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
8.8 KiB
Python
224 lines
8.8 KiB
Python
"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip.
|
|
|
|
Three primitives:
|
|
|
|
- `post_resolution_note` — post the engineer-edited resolution markdown to
|
|
the PSA ticket, store `{external_id, posted_at}` on the session.
|
|
- `post_escalation_package` — same pattern for the Escalate flow.
|
|
- `transition_ticket_status` — patch the ticket status, then re-fetch and
|
|
verify the change actually took. Failed verification raises loudly so the
|
|
UI never reports silent success (per the existing ConnectWise integration
|
|
principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md).
|
|
|
|
The target status IDs live in `account_settings.preferences`
|
|
(`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status
|
|
transition is a no-op and the endpoint response says so — we do not guess a
|
|
default because CW status IDs are board-specific.
|
|
|
|
Local-only path: callers handle sessions without `psa_ticket_id` before
|
|
calling this service. Nothing here tries to "post locally" — the service's
|
|
job ends at the PSA boundary.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.account_settings import AccountSettings
|
|
from app.models.ai_session import AISession
|
|
from app.services.psa.exceptions import PSAConnectionError
|
|
from app.services.psa.registry import get_provider_for_connection
|
|
from app.services.psa.types import NoteType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PSAStatusVerificationError(RuntimeError):
|
|
"""Raised when a ticket status transition didn't stick on re-fetch.
|
|
|
|
The `update_ticket_status` call returned OK but the subsequent
|
|
`get_ticket` still shows the prior status (or some unrelated one).
|
|
This is the exact failure mode CLAUDE.md flags as a ConnectWise
|
|
anti-pattern: reporting success when nothing changed.
|
|
"""
|
|
|
|
def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None:
|
|
super().__init__(
|
|
f"Ticket {ticket_id} status transition to {expected_status_id} "
|
|
f"did not verify — observed {observed_status!r} after re-fetch."
|
|
)
|
|
self.ticket_id = ticket_id
|
|
self.expected_status_id = expected_status_id
|
|
self.observed_status = observed_status
|
|
|
|
|
|
class PSAWritebackService:
|
|
"""Thin orchestration over the PSA provider for FlowPilot writebacks.
|
|
|
|
Instances are per-request — the AsyncSession is the one handling the
|
|
current HTTP call, and the provider is resolved lazily from the session's
|
|
`psa_connection_id`.
|
|
"""
|
|
|
|
def __init__(self, db: AsyncSession) -> None:
|
|
self.db = db
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────
|
|
|
|
async def post_resolution_note(
|
|
self, session: AISession, markdown: str
|
|
) -> dict[str, Any]:
|
|
"""Post `markdown` as a resolution note on the linked CW ticket.
|
|
|
|
On success, persists `resolution_note_markdown`, `_posted_at`,
|
|
`_external_id` on the session and returns the same triple. Caller is
|
|
responsible for committing the transaction.
|
|
"""
|
|
return await self._post_note(
|
|
session=session,
|
|
markdown=markdown,
|
|
note_type=NoteType.RESOLUTION,
|
|
markdown_col="resolution_note_markdown",
|
|
posted_at_col="resolution_note_posted_at",
|
|
external_id_col="resolution_note_external_id",
|
|
kind="resolution",
|
|
)
|
|
|
|
async def post_escalation_package(
|
|
self, session: AISession, markdown: str
|
|
) -> dict[str, Any]:
|
|
"""Post `markdown` as an escalation handoff note on the CW ticket."""
|
|
return await self._post_note(
|
|
session=session,
|
|
markdown=markdown,
|
|
# Internal-analysis visibility: the handoff is for the next engineer,
|
|
# not the customer. CW fires no notifications, keeps the note internal.
|
|
note_type=NoteType.INTERNAL_ANALYSIS,
|
|
markdown_col="escalation_package_markdown",
|
|
posted_at_col="escalation_package_posted_at",
|
|
external_id_col="escalation_package_external_id",
|
|
kind="escalation",
|
|
)
|
|
|
|
async def transition_ticket_status(
|
|
self,
|
|
session: AISession,
|
|
target_status_id: int,
|
|
) -> dict[str, Any]:
|
|
"""PATCH ticket status, then re-fetch and verify.
|
|
|
|
Returns `{"success": True, "verified_status_id": <int>, "verified_status_name": <str>}`
|
|
when the observed status matches. Raises `PSAStatusVerificationError`
|
|
when the transition didn't take (most common real-world failure: CW
|
|
requires certain fields before allowing a status change to
|
|
Resolved — the PATCH returns 200 but the status silently stays put).
|
|
"""
|
|
if not session.psa_ticket_id or not session.psa_connection_id:
|
|
raise ValueError("Session has no linked PSA ticket for status transition")
|
|
|
|
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
|
|
await provider.update_ticket_status(
|
|
ticket_id=session.psa_ticket_id, status_id=target_status_id,
|
|
)
|
|
|
|
# Verify by re-fetch — this is the load-bearing step.
|
|
verification = await provider.get_ticket(session.psa_ticket_id)
|
|
observed_id = getattr(verification, "status_id", None)
|
|
observed_name = getattr(verification, "status_name", None)
|
|
if observed_id != target_status_id:
|
|
raise PSAStatusVerificationError(
|
|
ticket_id=session.psa_ticket_id,
|
|
expected_status_id=target_status_id,
|
|
observed_status={"id": observed_id, "name": observed_name},
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"verified_status_id": observed_id,
|
|
"verified_status_name": observed_name,
|
|
}
|
|
|
|
async def resolved_status_id_for_account(
|
|
self, account_id: UUID
|
|
) -> int | None:
|
|
"""Return the configured CW "Resolved" status ID for the account.
|
|
|
|
None means "no transition configured" — callers should skip the
|
|
transition (posting the note is still meaningful). This lives in
|
|
account_settings.preferences per the Phase 1 JSONB grab-bag design.
|
|
"""
|
|
raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None)
|
|
return self._coerce_status_id(raw)
|
|
|
|
async def escalated_status_id_for_account(
|
|
self, account_id: UUID
|
|
) -> int | None:
|
|
raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None)
|
|
return self._coerce_status_id(raw)
|
|
|
|
# ── Internals ─────────────────────────────────────────────────────────
|
|
|
|
async def _post_note(
|
|
self,
|
|
*,
|
|
session: AISession,
|
|
markdown: str,
|
|
note_type: str,
|
|
markdown_col: str,
|
|
posted_at_col: str,
|
|
external_id_col: str,
|
|
kind: str,
|
|
) -> dict[str, Any]:
|
|
if not session.psa_ticket_id or not session.psa_connection_id:
|
|
raise ValueError(f"Session has no linked PSA ticket for {kind} post")
|
|
|
|
markdown = (markdown or "").strip()
|
|
if not markdown:
|
|
raise ValueError(f"{kind} markdown is empty")
|
|
|
|
try:
|
|
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
|
|
except PSAConnectionError:
|
|
# Connection could have been deleted or deactivated since session
|
|
# creation — propagate as a clear error for the endpoint to surface.
|
|
logger.exception(
|
|
"PSA connection %s is no longer available for session %s",
|
|
session.psa_connection_id, session.id,
|
|
)
|
|
raise
|
|
|
|
posted = await provider.post_note(
|
|
ticket_id=session.psa_ticket_id,
|
|
text=markdown,
|
|
note_type=note_type,
|
|
)
|
|
|
|
posted_at = datetime.now(timezone.utc)
|
|
setattr(session, markdown_col, markdown)
|
|
setattr(session, posted_at_col, posted_at)
|
|
setattr(session, external_id_col, str(posted.id) if posted.id else None)
|
|
|
|
return {
|
|
"external_id": str(posted.id) if posted.id else None,
|
|
"posted_at": posted_at,
|
|
"kind": kind,
|
|
}
|
|
|
|
@staticmethod
|
|
def _coerce_status_id(raw: Any) -> int | None:
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
logger.warning(
|
|
"Non-integer CW status ID in account_settings.preferences: %r",
|
|
raw,
|
|
)
|
|
return None
|