120 Commits

Author SHA1 Message Date
7cee7228dc docs(ai): refresh handoff for PR #156 — pending-verification feature
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
CI / frontend (pull_request) Successful in 5m9s
CI / backend (pull_request) Successful in 9m51s
CI / e2e (pull_request) Successful in 9m22s
Closes out Escalation Mode (PR #155 merged) and pivots active task to
the new applied_pending suggested-fix outcome on PR #156.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 17:37:08 -04:00
00663a4734 feat(suggested-fix): add applied_pending status for deferred verification
Some checks failed
Mirror to GitHub / mirror (push) Has been cancelled
CI / backend (pull_request) Successful in 10m43s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Successful in 11m13s
Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.

Backend
- New non-terminal status `applied_pending` parallel to `applied_partial`.
- New `pending_reason` column (nullable Text) — the "what are you waiting on?"
  prose, mirrors `partial_notes`. Required when outcome=applied_pending.
- Outcome endpoint allows pending in/out transitions; pending stamps
  applied_at but NOT verified_at (it's parked, not verified).
- Resolution-note + escalation-package prompts handle the new status:
  resolution note frames the fix as provisional; escalation package surfaces
  pending verification as the leading hypothesis with reference to what's
  being waited on.
- Migration: add column + extend status CHECK constraint.

Frontend
- New `BannerMode = 'pending'` + `PendingBanner` component (info-tone,
  parallel to PartialBanner) with worked / didn't / update-reason actions.
- VerifyingBanner overflow menu adds "Waiting to verify…".
- Nudge banner's "Still checking" button now actually records pending with
  a reason, instead of just silencing for the session.
- AssistantChatPage banner-mode derivation maps applied_pending → 'pending'.

Tests: 4 new integration tests covering pending notes requirement, reason
storage + applied_at/verified_at semantics, pending→success transition,
and pending_reason update on re-PATCH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 17:32:37 -04:00
ac42f971fc Merge PR #155: Escalation Mode wedge — live arrival + magic-moment pickup
All checks were successful
CI / frontend (push) Successful in 5m7s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m36s
CI / backend (push) Successful in 11m9s
Magic-moment handoff-context screen on senior pickup, live SSE escalation arrivals, time-to-first-action metric, role-gated claim with atomic conflict resolution, and chat ownership extension for claimed sessions.
2026-04-30 21:32:16 +00:00
f10649abc2 fix(escalations): atomic claim + self-claim rejection + queue exclusion
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 4m59s
CI / backend (pull_request) Successful in 10m22s
CI / e2e (pull_request) Successful in 10m46s
Codex review pass on the escalation wedge. Reworks claim_session from
read-then-write to a conditional UPDATE so two seniors racing can't both
win, blocks the original engineer from claiming their own handoff, and
filters self-escalated sessions out of the dashboard escalation queue.
Also preassigns the handoff UUID before flush so the compatibility
escalation_package payload carries it. Removes legacy frontend pickup
state (claiming, handleStartHere) that broke tsc --noEmit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:21:20 -04:00
ab5e0deaf7 docs(ai): session 3 handoff — QA complete, chat ownership decision logged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 01:32:39 -04:00
f601a0db58 docs(ai): QA complete — escalation mode wedge browser-verified
All paths pass. One critical fix: chat endpoint now allows escalated_to_id
as a valid sender so the senior can run AI analysis on claimed sessions.
PR #155 ready for review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:26:18 -04:00
dc69c9ddfb fix(escalations): allow claimed-by user to send chat messages to escalated session
unified_chat_service.send_chat_message checked AISession.user_id == user_id,
blocking the senior who claimed an escalation from sending the AI briefing.
Now also allows AISession.escalated_to_id == user_id (the claimer).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:17:31 -04:00
db717b0b3f feat(escalations): magic-moment 3-option CTA + claim 500 fix
- HandoffContextScreen: 3-option layout (Continue/AI analysis/Own thing)
  with hasTaskLane, activeOptionKey, spinner/disabled states
- AssistantChatPage: wire up handleContinue, handleAIAnalysis, handleOwnThing
  handlers; chip detail expansion inline with copy-button fix; post-escalation
  redirect to dashboard on ConcludeSessionModal close
- TaskLane: fix async copy button (await + execCommand fallback + copiedKey
  visual feedback); whitespace-pre-wrap on command blocks
- Fix 500 on claim: Pydantic v2 model_validate() + model_copy(update={})
  (was passing update= kwarg directly which v2 rejects)
- HandoffResponse schema: handed_off_by_name field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 00:05:02 -04:00
fb2dc222fd docs(ai): handoff for fresh session — AI consolidation plan locked
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m9s
CI / backend (pull_request) Successful in 9m43s
CI / e2e (pull_request) Successful in 10m13s
- HANDOFF: rewritten resume point. AI summary blocker is the active
  task; consolidation plan is the path. 5-step implementation order
  with watch-outs and breadcrumbs.
- CURRENT_TASK: updated commit table through 0d1b305. Documents the
  live-test results (what works, the AI summary blocker), full
  consolidation design with proposed payload shape.
- SESSION_LOG: chronological entry covering live QA bash, two
  pickup bugs found + fixed, the three Enter/dashboard/timeout
  fixes, and the architectural smell that surfaced.
- DECISIONS: new entry "Consolidate the three per-escalation AI
  calls into one structured generation" — rejected alternatives
  (bump timeout further, copy status-update content the wrong way,
  switch to Haiku) and consequences (5s magic-moment, ~60% token
  reduction, instant Ticket Notes button, schema enforcement
  required, migration concerns documented).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 00:21:30 -04:00
0d1b305619 fix(escalations): live-test fixes from QA bash
Bundles four fixes from the live debugging session:

1. AssistantChatPage: replace urlSessionId === activeChatId gate with a
   loadedChatIdsRef. After 8914391 made activeChatId initialize from
   urlSessionId, the gate short-circuited fresh mounts and selectChat
   never fired. Symptom: senior picks up an escalation, lands on a blank
   chat surface with no conversation history and no sidebar entry. Fix
   also adds loadChats() in handleStartHere so the picked-up session
   appears in the sidebar (its escalated_to_id is null pre-claim, so
   listSessions doesn't return it until claim_session sets it).

2. config: bump ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS 15s → 45s.
   Sonnet was hitting tail latency at 15s in the field, leaving the
   magic-moment placeholder permanent. Background-task architecture
   (e8ba74e) means this no longer blocks the user; it's just the budget
   before publishing has_assessment=false. NOTE: live test still shows
   assessment not populating — see HANDOFF for the consolidation plan
   that supersedes this.

3. Enter-to-submit: chat-input convention (Enter submits, Shift+Enter
   inserts newline) on the escalate-flow forms. RichTextInput gains an
   optional onSubmit prop; EscalateModal wires it to handleSubmit;
   ConcludeSessionModal gets the same handler on its plain textarea.

4. PendingEscalations: each row is now expandable. Click row body to
   reveal the engineer's escalation reason, step count on record,
   confidence tier, and PSA ticket number. Pick Up still clicks through
   directly. Single-expand-at-a-time keeps the dashboard compact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 00:18:40 -04:00
b7d7ff06d2 docs(ai): refresh handoff for compute swap
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m8s
CI / backend (pull_request) Successful in 9m46s
CI / e2e (pull_request) Successful in 10m16s
- HANDOFF: rewritten resume point. First action on resume is `git push`
  (commits 0f00ee5 and 665530f are local-only). Visual QA + bug bash is
  the active work; 4 plan-locked items + the structural task-lane fix
  all need real-browser verification.
- CURRENT_TASK: add 0f00ee5 and 665530f to the commit table; reframe
  "Just shipped" as a per-commit summary; flag the task-lane fix as
  needing visual confirmation.
- SESSION_LOG: chronological entry for this session with full detail
  (audit, four polish items, race-condition wiring, structural
  task-lane fix, test status, files touched).
- DECISIONS: new entry "Tag the task-lane state with an owner chatId"
  documenting the structural pattern, what was rejected, and the
  forward implication that future task-lane state slices follow the
  same owner-tagging pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:21:23 -04:00
665530f812 fix(assistant-chat): tag task-lane state with owner chatId to kill stale flash
The previous fix (8914391) only blocked the mount-time sessionStorage
restore when the page entered with prefill or ?pickup=true. It didn't
cover any path where the page was already mounted and activeChatId
flipped without the in-memory task-lane state going through reset+
repopulate cleanly — in-place URL navigation, mid-flight pickup,
HMR re-runs, the gap between setActiveChatId(B) and the AI response
that finally populates B's questions/actions.

Root cause: activeQuestions / activeActions / showTaskLane were never
intrinsically tied to a chatId. They were treated as "the active chat's
data" by convention, with no structural enforcement. Any window where
they survived past their owning chat leaked previous-session data into
the new view. The persistence effect made it worse: it stamped the
sessionStorage chatId field with activeChatId at write time, so a
mid-transition snapshot {chatId: B, questions: [A's]} would happily
restore A's data for B on the next mount.

Fix: introduce taskLaneOwnerChatId state that records the chatId those
in-memory questions/actions/show values BELONG to. Set at every site
that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit,
handleResumeNew, refreshFacts, handleApplyFix). Cleared in
resetSessionDerivedState. The persistence effect now writes ownerChatId
as the chatId tag, not activeChatId — so the snapshot is always
self-consistent.

Render gate: taskLaneIsForActiveChat = ownerChatId === activeChatId.
ANDed into all three render conditions (toolbar Tasks button, narrow-
viewport floating drawer, main side panel). The lane is structurally
unable to display data tagged with a different chat.

The mount-time skipTaskLaneRestore guard stays — it kills the flash
between component mount and the first sendPrefill effect run, which
the owner-gate alone doesn't cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 02:42:31 -04:00
0f00ee5e01 feat(escalations): close out plan-locked wedge polish
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.

1. Live AI assessment refresh on the magic-moment screen. Backend already
   publishes handoff_assessment_ready when enrich_escalation_async commits;
   wire the frontend listener so the senior sees the assessment populate
   without a manual reopen. New event type + onAssessmentReady handler on
   streamEscalations; AssistantChatPage opens a scoped SSE subscription
   whenever it tracks a handoff missing its assessment, refetches on match,
   and replaces magicHandoff / overlayHandoff in place. Closes the loop on
   the async-assessment commit e8ba74e.

2. Suggested-step chips below the chat input. Locked design from the plan
   (Codex correction). Chip strip renders above the composer post-claim
   when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
   the input and focuses; first send or explicit X hides for the session.

3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
   set (rf-escalation-seen, capped 200). Dot top-right when not seen.
   Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
   Codex correction. Pick Up stops propagation so it doesn't double-fire.

4. Race-condition toast on claim conflict. The /claim endpoint previously
   silently overwrote claimed_by — both seniors thought they owned the
   session. New HandoffAlreadyClaimedError carries the winner's id/name/
   timestamp; claim_session rejects different-user re-claims (same-user is
   idempotent for double-click safety); endpoint returns 409 with
   structured detail. AssistantChatPage.handleStartHere extracts and
   surfaces "Already claimed by {name} {time_ago}." via toast, drops
   ?pickup=true, dismisses magic-moment so the loser flows back to queue.

Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 01:59:28 -04:00
8914391336 fix(assistant-chat): kill stale task-lane flash on new-session entry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m4s
CI / backend (pull_request) Successful in 10m9s
CI / e2e (pull_request) Successful in 10m8s
Two compounding bugs caused the previous session's questions/actions
to render briefly when entering a new chat — visible as "the new
session instantly pops with old session task-lane data" the user
reported.

The race
- AssistantChatPage's activeQuestions / activeActions / showTaskLane
  useState initializers synchronously read sessionStorage's
  rf-tasklane-meta. They restore the persisted task-lane state if its
  saved chatId matches the freshly-resolved activeChatId.
- On dashboard prefill flow, the page mounts on /pilot with
  location.state.prefill set; activeChatId initializes from
  sessionStorage's rf-active-chat-id (the previous session). The
  previous session's task-lane meta matches that chatId — so the
  initializer restores it. First paint shows old questions/actions.
  sendPrefill's resetSessionDerivedState fires later from a useEffect,
  but only after the flash.
- Same pattern hits the senior-pickup flow: ?pickup=true means we're
  about to render the magic-moment screen and discard whatever chat
  the senior was previously on, but the underlying chat surface still
  initializes with their old task-lane meta.

The amplifier
- resetSessionDerivedState wiped the in-memory state but never
  removed sessionStorage's rf-tasklane-meta. Any remount or reload
  before the next persistence-effect write could re-hydrate the
  cleared state from the still-stale sessionStorage entry.

Fixes
- Initializer guard: when location.state.prefill is set OR
  ?pickup=true is in the URL, skip the sessionStorage restore
  entirely. Kills the first-paint flash for both entry paths.
- Eager wipe: resetSessionDerivedState now also calls
  sessionStorage.removeItem('rf-tasklane-meta'). The persistence
  effect re-saves on the next state change anyway, so the only
  window where sessionStorage is empty is the exact window where
  stale-tag leakage was happening.

tsc -b clean. No backend changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 01:26:29 -04:00
e8ba74ed6d feat(escalations): distinguishable notifications, async AI, richer sidebar
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6m5s
CI / frontend (pull_request) Successful in 11m59s
CI / e2e (pull_request) Successful in 10m7s
CI / backend (pull_request) Successful in 16m22s
Three improvements driven by live wedge testing.

1) Notification title now includes a problem snippet and PSA ticket
   suffix when present:
     "Escalation from Jane · #12345: Outlook is failing to sync email…"
   Replaces the prior "Session escalated by Jane" copy that made every
   escalation from the same junior look identical in the bell panel.
   Snippet is trimmed to 70 chars with ellipsis. handoff_manager now
   passes psa_ticket_id through in the notify() payload so this works
   for both /escalate and /handoff entry points.

2) AI enrichment (assessment + enhanced escalation_package) moved to
   a FastAPI BackgroundTask. The escalating engineer no longer waits
   on 15-25s of Sonnet latency — handoff creation returns as soon as
   snapshot, status flip, dual-write, documentation, PSA push, and
   notify() are committed. enrich_escalation_async opens its own DB
   session, runs both AI calls, updates handoff.ai_assessment +
   session.escalation_package, commits, and publishes a new
   `handoff_assessment_ready` event on the escalation bus. Frontend
   doesn't yet listen for that event — the magic-moment screen still
   shows a placeholder ("AI assessment is still generating. Reopen
   this view in a few seconds…") which is honest about the state.
   Live polling / auto-refresh on the bus event is the natural next
   step.

3) ChatSidebar entries now surface the problem summary as a secondary
   line and tag PSA-linked sessions with a monospace #ticket badge plus
   an "Escalated" pill on in-transit sessions. ChatListItem grew
   problem_summary, psa_ticket_id, and status fields; loadChats
   populates them from listSessions. The user couldn't tell their own
   sessions apart in the sidebar because they all rendered as "New
   Chat" with no distinguishing detail — this fixes that for any
   session, escalated or not.

Test plan
- Backend full suite: 1103 passed in 255.85s with -n auto.
- Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:34:32 -04:00
aca915b047 fix(escalations): bump assessment timeout, surface picked-up sessions in sidebar
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m6s
CI / backend (pull_request) Successful in 9m45s
CI / e2e (pull_request) Successful in 10m20s
Two field-reported issues from live wedge testing.

ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS bumped 5s → 15s. The 5s bound
fired too aggressively against the Sonnet diagnostic assessment prompt;
~4-8s is typical but tail latency hits 12-14s. The fallback "Assessment
unavailable — model didn't respond in time" placeholder was showing on
the magic-moment screen for two consecutive escalations, which kills
the demo. 15s keeps the click-path bounded but lets the typical case
return real content. Real fix is async generation (kick off, persist
when done, surface "still computing" with refresh) — captured as a
follow-up; bumping the bound is the right call for the wedge demo.

list_sessions now matches escalated_to_id == current_user.id alongside
the existing user_id and escalation_package.picked_up_by clauses. The
unified HandoffManager.claim_session sets escalated_to_id but doesn't
write the legacy picked_up_by JSONB key, so picked-up sessions never
showed in the senior's chat list — the senior would land on the
session detail (active chat) but the sidebar showed only their other
unrelated sessions. User reported this as "4 different versions of the
session in the chat history section" — they were actually 4 unrelated
empty sessions the senior owned, plus the picked-up session was just
invisible. Backend tests still 94/94.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:04:08 -04:00
e910bcc67d fix(escalations): wire magic-moment + claim into AssistantChatPage
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m0s
CI / backend (pull_request) Successful in 10m2s
CI / e2e (pull_request) Successful in 10m39s
The /pilot/:id route renders AssistantChatPage, not FlowPilotSessionPage
(the latter is dead code with no active route). The earlier magic-moment
integration sat in the wrong file, so clicking Pick Up from the
dashboard navigated to /pilot/:id?pickup=true and AssistantChatPage
just loaded the chat surface with no claim — the senior never saw the
magic-moment screen and the handoff stayed unclaimed (status escalated,
permanently in the queue).

Adds full pickup awareness to AssistantChatPage:

- ?pickup=true on entry triggers a handoff fetch via
  handoffsApi.listHandoffs (account-scoped, no claim required).
  magicState transitions loading → visible (handoff found) or
  loading → dismissed (no handoff or fetch failed). The dismiss path
  also strips ?pickup=true from the URL so a refresh doesn't re-enter
  loading state.
- The existing selectChat-from-URL effect is gated on magicState — it
  skips while we're loading or showing the magic-moment so the chat
  surface doesn't race the claim flow. After claim it re-fires and
  populates messages from conversation_messages because the senior is
  now escalated_to_id and GET succeeds.
- Magic-moment renders as full-page take-over (sidebar hidden) until
  Start here. handleStartHere calls handoffsApi.claimHandoff, drops
  ?pickup=true, and dismisses — the regular chat then loads.
- Toolbar Context button (visible when magicHandoff is in memory)
  re-opens the screen as a dismissible overlay. Lazy-fetches the
  handoff when needed.

Verified tsc -b clean and Vite HMR picked the file up without errors.
The wire-level integration was already verified in earlier commits:
listHandoffs returns the unclaimed handoff for a senior pre-claim,
claimHandoff flips status escalated → active and sets escalated_to_id.

Note: the prior FlowPilotSessionPage magic-moment integration is now
in dead code (file is unreferenced from router). Left in place for
this commit; will come out in a follow-up cleanup once we're confident
the AssistantChatPage path is solid in production.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:23:00 -04:00
5085bb47c2 docs(ai): handoff state after /escalate unification through HandoffManager
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (pull_request) Successful in 10m3s
CI / frontend (pull_request) Successful in 5m34s
CI / e2e (pull_request) Successful in 9m26s
Records 029680a — every escalation now funnels through HandoffManager
regardless of which URL it entered through, so /escalate from
EscalateModal produces the full set of artifacts (handoff row,
AppNotification, SSE event, Slack/Teams via notify, per-user emails,
documentation, PSA push) and the bell-icon notification opens the
magic-moment screen end-to-end. Notes the legacy SessionBriefing branch
+ flowpilot_engine.escalate_session as orphaned, scheduled for removal
after pilots have run a couple of weeks on the unified path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:29:40 -04:00
029680ab2d feat(escalations): unify /escalate through HandoffManager
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m8s
CI / backend (pull_request) Successful in 10m13s
CI / e2e (pull_request) Successful in 10m47s
Replaces the legacy flowpilot_engine.escalate_session orchestration with
a single canonical path through HandoffManager. Every escalation now
creates a SessionHandoff row, fans out via the SSE bus, persists
AppNotification rows for the bell icon, dispatches to external channels
(Slack/Teams) via notify(), and emails per-user — regardless of whether
the call entered through /escalate (legacy URL) or /handoff (new URL).
The senior-pickup magic-moment screen now works end-to-end from the
EscalateModal bell-icon path the user just tested.

Backend
- HandoffCreateRequest gains optional target_user_id (the equivalent of
  the legacy escalated_to_id field). Self-targeting rejected.
- HandoffManager.create_handoff handles intent='escalate' end-to-end:
  sets escalation_reason + escalated_to_id, builds the legacy enhanced
  AI escalation_package (Sonnet, lazy-imported from flowpilot_engine,
  graceful fallback on failure), and merges handoff metadata into it.
  Eager-loads session.steps and session.user via selectinload — required
  by both the enhanced-package builder and notify() to avoid
  MissingGreenlet on async lazy access.
- HandoffManager.finalize_escalation generates SessionDocumentation,
  pushes documentation to PSA, and runs notify() — pre-commit so the
  AppNotification rows persist atomically with the handoff.
- HandoffManager.dispatch_escalation_notifications keeps only the
  fire-and-forget IO (bus publish, per-user emails) — runs post-commit.
  Pulls engineer name via a separate User query rather than relying on
  session.user lazy access.
- /handoff endpoint passes target_user_id through and calls
  finalize_escalation pre-commit.
- /escalate endpoint is now a thin shim: owner-only session lookup,
  HandoffManager.create_handoff(intent='escalate'), finalize_escalation,
  commit, dispatch_escalation_notifications, return SessionCloseResponse
  built from documentation + psa_result. flowpilot_engine.escalate_session
  is no longer called by any endpoint.
- pickup_session accepts both 'requesting_escalation' (legacy in-flight
  sessions) and 'escalated' (new canonical) so the migration is seamless
  for sessions already in the queue.
- Escalation queue list and sidebar count now match either status.

Frontend
- useFlowPilotSession optimistic update flips status to 'escalated'
  instead of 'requesting_escalation' so the page state matches the
  unified backend response.

Verified end-to-end live: a fresh /escalate call from the junior produces
status='escalated', a SessionHandoff row, a SessionDocumentation, PSA
push attempted (no_psa for this test session), AND a bell-icon
AppNotification for the team admin with link
/pilot/{session_id}?pickup=true. Backend test suite: 1103 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:27:26 -04:00
2a2329ad19 docs(ai): handoff state after bell-icon fix; record draft PR #155
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m41s
CI / backend (pull_request) Successful in 9m55s
CI / e2e (pull_request) Successful in 9m13s
Updates the handoff trio after the legacy notification flow fix and
the branch push. PR #155 is open against main as draft. Resume point
is now visual QA via /qa, then deferred follow-ups (chat-input
suggested-step chips, snapshot expansion). Logs the open question
about whether EscalateModal should switch to /handoff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:33:44 -04:00
641853a002 fix(escalations): bell-icon notification opens the pickup flow
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 1m17s
CI / frontend (pull_request) Successful in 4m53s
CI / e2e (pull_request) Successful in 9m18s
Two backend changes that unbreak the senior-pickup path from the
notification panel:

1. notification_service: session.escalated link template now ends with
   ?pickup=true so the senior lands in the handoff/pickup flow on
   click. Without it, navigation hit /pilot/:id directly, which then
   404'd on the GET because the senior isn't yet escalated_to_id —
   the user perceives this as the bell-icon "just clearing the
   notification".

2. ai_sessions GET access: any account member can now read an escalated
   session's detail when status is requesting_escalation or escalated.
   The owner-only guard was overly restrictive for explicitly-shared
   in-transit states. Tenant boundary is enforced by RLS on the
   underlying query, so account-scope is the right ceiling here. After
   pickup, the existing handler/escalated_to_id checks still apply.

Verified live: re-login as the senior engineer and GET the active
escalated session — now returns 200 with full detail. Focused test
subset plus tests/test_sessions.py and tests/test_session_sharing.py
→ 94 passed in 43.26s, no regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:29:47 -04:00
c194ba4a43 docs(ai): handoff state after magic-moment screen lands
Marks the magic-moment handoff-context screen as shipped, points the
next session at visual QA + push + draft PR, and captures the deferred
follow-ups (suggested-step chips, snapshot expansion, toolbar button
on revisits, owner analytics, Playwright e2e).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:08:07 -04:00
8e9d22e0e0 feat(escalations): magic-moment handoff-context screen on pickup
Adds the dedicated 4-section handoff-context view that renders BEFORE
the FlowPilot session for senior techs picking up an escalated
session, then dissolves on "Start here". This is the wedge's
demonstrable magic moment — what the GTM Loom records.

- HandoffContextScreen.tsx: pure presentational, takes a HandoffResponse
  plus onStartHere / onDismiss callbacks. Sections: header
  (problem summary, domain, step count, escalated-time, priority badge),
  "What's been tried" (engineer notes + step-count affordance), "AI
  assessment" (likely_cause / suggested_steps / confidence badge), Start
  here CTA. Confidence badge accepts both numeric (0..1) and string
  ("low"/"medium"/"high") shapes — backend currently emits the latter.
  Renders an explicit "assessment unavailable" branch when
  ai_assessment_data is null (the 5s timeout from 9bdd995 fired).
  Honors prefers-reduced-motion (animate-fade-in vs animate-slide-up).
  ARIA dialog + focus on the primary CTA. Esc dismisses when used as a
  re-openable overlay; pre-claim, Start here is the only exit.

- FlowPilotSessionPage.tsx: on /pilot/:id?pickup=true, fetch the
  handoff list via handoffsApi.listHandoffs (account-scoped via RLS,
  no claim required) and find the latest unclaimed escalate handoff.
  If found, render the magic-moment screen and skip the regular
  loadSession (the senior isn't yet escalated_to_id, so GET would
  404). Start here calls claimHandoff, drops the pickup query param,
  dismisses the screen — the existing loadSession effect then fires
  because the senior is now escalated_to_id. A "Context" toolbar
  button on active sessions re-opens the screen as a dismissible
  overlay (visible only when the senior arrived via the magic-moment
  flow this session — handoff lookup on demand).

Verified end-to-end against the running dev stack: listHandoffs
returns the unclaimed handoff with full payload; claim flips session
status from escalated → active; subsequent GET succeeds. tsc -b clean.

Defers (TODO followups): suggested-step chips below the chat input
that prefill on click (requires threading through to
FlowPilotMessageBar); snapshot expansion to include the recent
diagnostic steps pre-claim; toolbar Context button on sessions where
the senior didn't arrive via magic-moment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:06:14 -04:00
f65b65790c docs(ai): handoff state after frontend SSE slice lands
Marks the SSE subscription as shipped, points the next-session resume
target at the magic-moment handoff-context screen, and logs the live
end-to-end verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:57:20 -04:00
b8627f4180 feat(escalations): subscribe EscalationQueue to live SSE arrivals
Adds the frontend live-arrival slice on top of the test-stabilized SSE
backend. Senior techs now see a junior's escalation slide into the
queue without refresh.

- streamEscalations(handlers, signal) in aiSessions.ts: fetch-based
  ReadableStream parser (native EventSource cannot send auth headers).
  Handles SSE frames, partial frames across chunks, : keepalive
  heartbeats. Dispatches ready and handoff_created.
- HandoffCreatedEvent + EscalationStreamHandlers types mirror the bus
  payload published by HandoffManager.dispatch_escalation_notifications.
- EscalationQueue.tsx: AbortController-managed subscription with
  exponential-backoff reconnect (1s → 30s cap, attempt counter resets
  on ready). On handoff_created, refetch and diff against previous IDs
  via sessionsRef; new arrivals prepended (newest-first) above
  established cards (oldest-first preserved). Slide-in tag held for
  800ms so the locked 200ms animation completes. Tab-title flash
  prefixes (N) while document.hidden, restores on focus / unmount.
  prefers-reduced-motion swaps slide-in for fade-in. ARIA region +
  aria-live=polite + aria-label on heading. Pick Up bumped to py-2.5
  to clear the 44px touch floor.

Verified end-to-end against the running dev stack: subscriber received
the ready frame on connect; after posting a handoff via the API, the
subscriber received the handoff_created frame with the expected
payload — wire format matches the parser. Backend regression: focused
subset still 32 passed in 18.91s. Frontend tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:57:15 -04:00
02d5c6c08c docs(ai): refresh handoff state for next-session pickup under 200k context
Default Claude Code model is being switched from Opus 4.7 1M-context to
Opus 4.7 (200k). Tighten the per-session pickup docs so they're
self-sufficient under the smaller window:

- CURRENT_TASK now reflects the post-Codex state: 8 commits on the
  branch (5 feat + WIP SSE + 2 Codex test/latency fixes + 1 doc
  refresh), 32/32 backend tests with -n auto, frontend tsc -b clean.
  Remaining work re-scoped: the SSE backend half is feature-complete
  and tested, so what's left is the FRONTEND SSE subscription in
  EscalationQueue.tsx, then the magic-moment handoff-context screen,
  then push + draft PR.
- Session log gets a Claude Code entry covering today's planning →
  build → pause-for-Codex arc, the design decisions locked into the
  doc and code, the two TODOs added (peer-tech escalation, mobile
  responsive), and the model-switch context for the next session.
- HANDOFF.md needs no change — Codex's update in 9bdd995 already
  describes the resume point and watch-outs cleanly.

No code change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:13:40 -04:00
9bdd9959a8 fix(handoff): bound escalation assessment latency
Co-Authored-By: Codex <noreply@openai.com>
2026-04-27 20:03:14 -04:00
fff8338bf2 docs(ai): track escalation assessment latency follow-up
Co-Authored-By: Codex <noreply@openai.com>
2026-04-27 19:55:31 -04:00
bc15952857 fix(tests): stabilize escalation SSE backend tests
Co-Authored-By: Codex <noreply@openai.com>
2026-04-27 19:47:43 -04:00
ba46fc5644 docs(ai): pause Escalation Mode build mid-SSE for Codex review
Update HANDOFF to reflect:
- Build paused after the WIP SSE commit (87bd0b7)
- What Codex should look at on the SSE bus + endpoint + dispatch wiring
- Resume point post-review: re-run tests with -n auto, then frontend
  SSE subscription, then magic-moment screen
- Test-suite watch-out: per-test DROP SCHEMA fixture means concurrent
  pytest runs on the same DB collide; always one-suite-at-a-time or
  -n auto with conftest's per-worker DB isolation

No code change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:29:16 -04:00
87bd0b7c56 WIP: SSE pub/sub for live escalation arrivals (paused for Codex review)
First half of the WebSocket/SSE push slice. Paused mid-flight to hand
the branch to Codex for outside-voice review before stacking more
commits on top. See .ai/HANDOFF.md for the full pause context + what
to look at.

What's here:
- backend/app/core/escalation_bus.py — module-level singleton in-memory
  pub/sub keyed by account_id. asyncio.Queue per subscriber with
  64-event maxsize and drop-on-full semantics. Designed to be swappable
  for Redis pub/sub when Railway scales past single-replica.
- backend/app/api/endpoints/session_handoffs.py — GET
  /api/v1/ai-sessions/escalations/stream SSE endpoint. Auth via
  require_engineer_or_admin. 25s heartbeat. Account-scoped subscribe
  bound to current_user.account_id.
- backend/app/services/handoff_manager.py — dispatch_escalation_notifications
  now publishes a `handoff_created` event to the bus BEFORE the email
  fan-out, in a try/except so a bus failure can't block email delivery.
- backend/tests/test_escalation_bus.py — 7 unit tests, all green
  standalone (0.14s). Cross-tenant isolation, drop-on-full, no-subscribers.
- backend/tests/test_handoff_manager.py — +1 dispatcher integration test
  (publishes to bus, payload shape).
- backend/tests/test_session_handoffs_api.py — +2 endpoint tests (viewer
  blocked, ready event handshake).

[gstack-context]
Decisions:
  - SSE over WebSocket (one-way, browser EventSource semantics, fewer
    moving parts behind Railway proxy)
  - In-memory bus over Redis for v1 pilot (3 MSPs, single replica)
  - Drop-on-full subscriber queue rather than back-pressure publishers
  - Bus publish ahead of email send, both wrapped in try/except so
    neither can break handoff creation
  - Frontend will be a fetch-based ReadableStream reader matching the
    existing streamDocumentation pattern, not native EventSource
    (custom-header auth)
Remaining (post-Codex):
  - Frontend SSE subscription in EscalationQueue.tsx (slide-in,
    reconnect, tab-title flash, prefers-reduced-motion)
  - Magic-moment handoff-context screen
  - Re-run the full backend test suite to verify the SSE +
    dispatcher integration tests (bus units already green standalone)
Tried:
  - Running the full test suite repeatedly without xdist; the per-test
    DROP SCHEMA + recreate fixture made wall-clock prohibitive when
    multiple stale runs collided on the same Postgres test schema.
    Resolution: -n auto next time.
[/gstack-context]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:29:07 -04:00
a283d0d3fd docs(ai): refresh handoff state mid-flight on Escalation Mode build
Capture the in-flight state of the Escalation Mode wedge build so the next
session (or Codex resume) picks up cleanly without re-deriving context:

- CURRENT_TASK now describes the wedge, what's done across the 5 commits on
  this branch, what remains (WebSocket push, magic-moment screen, analytics
  page, e2e), and the two-metric framing readers MUST internalize before
  quoting numbers
- HANDOFF resume point is the WebSocket/SSE push (live-arrival half of the
  notification dual-path); includes suggested first slice + watch-outs
  (no user_id on ai_session_step, denormalized account_id, peer-escalation
  still gated to session owner)
- Both files reference the design doc and the kill-switch criterion

No code change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 16:38:14 -04:00
9f0bfd44f9 feat(escalations): mount time-to-first-action stat-card on /escalations
Surfaces the new GET /analytics/flowpilot/escalations endpoint as a card
above the EscalationQueue list. Closes the loop from yesterday's metric
endpoint commit — seniors and owners see the wedge stat the moment they
open the queue, which is the daily-reps version of the GTM ROI story.

Pieces:
- EscalationMetrics TS interface mirroring the backend Pydantic model
  (incl. metric_definition disclaimer field)
- flowpilotAnalyticsApi.getEscalationMetrics(period) client method
- EscalationMetricCard component:
    * loading skeleton, error state, zero-data empty state
    * avg + median + n_with_action/n_claimed conversion rate
    * humanized seconds → "Ns" / "N.N min" formatting
    * inline disclaimer reminding callers this is in-product time-to-
      first-action only, NOT the savings claim — pair with manual
      baseline (per /codex review's two-metric correction)
- Wired into EscalationQueuePage above EscalationQueue

DS-aligned: card-flat, accent-dim usage held to interactive elements,
text-muted-foreground for secondary copy, font-heading on the headline
number, explicit transition properties (no `transition: all`). Respects
prefers-reduced-motion implicitly (only animation is the loading pulse,
which Tailwind's animate-pulse already gates).

tsc -b clean. No new tests in this commit — component is a thin
state-machine over an axios call; integration coverage comes from the
existing backend tests + the e2e Playwright work in the plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 16:00:34 -04:00
07d0db9579 feat(handoff): email engineer-or-admin teammates on escalation
First half of the Escalation Mode notification dual-path. WebSocket/SSE
push is the second half (next commit) — email handles offline seniors,
push handles online ones for the magic-moment demo.

HandoffManager.dispatch_escalation_notifications:
- Pulls active engineer/admin/owner-role users in the same account_id
  (excludes the escalator + viewers + soft-deleted)
- Sends via existing EmailService.send_notification_email, concurrent
  via asyncio.gather; per-message failures don't block the rest
- Wrapped in try/except: any exception is logged + swallowed. Handoff
  creation is authoritative; notification is advisory. This is the
  graceful-degradation regression both eng + codex reviews flagged as
  critical (handoff must succeed even if SMTP is down).

Endpoint wiring (POST /ai-sessions/{id}/handoff):
- Dispatch fires AFTER db.commit() — never email about a rolled-back
  handoff. Trust-erosion bug if we got that wrong.
- Only fires for intent=escalate. Park is private to the escalator.

Tests (4 new):
- emails-engineer-recipients-in-account: viewer excluded, escalator
  excluded, only the engineer/admin teammates get the message
- skipped-for-park-intent: park doesn't fan out
- graceful-degradation-when-email-raises: RuntimeError from the email
  service does NOT bubble out of dispatch
- endpoint-dispatches-on-escalate: end-to-end wiring through POST

Per-channel delivery records (replacing the dead `notification_sent`
boolean per Codex correction) is a v1.x story — for now application
logs are the audit trail. See
docs/plans/2026-04-27-escalation-mode-wedge-design.md.

20 tests green across handoff_manager + session_handoffs_api +
flowpilot_analytics_escalations. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:58:05 -04:00
7a5b853b3b feat(api): role-gate handoff claim to engineer-or-admin
POST /ai-sessions/{id}/handoffs/{hid}/claim previously required only an
authenticated user, so a viewer-role account user could claim escalations.
Codex review flagged this as wedge-relevant: the Escalation Mode race-
condition story (two seniors clicking Pick Up simultaneously) depends on
auth gating for audit integrity. Originally captured as a deferred TODO
during /plan-eng-review, then moved in-scope by /codex review.

Swap the dep to require_engineer_or_admin. One-line change. Two new tests:
- viewer_role gets 403 with "Engineer or admin access required"
- engineer/owner role still succeeds and claimed_at + claimed_by populate

Existing handoff create + queue tests unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:46:59 -04:00
52f6d0308f feat(analytics): add escalation time-to-first-action metric endpoint
GET /api/v1/analytics/flowpilot/escalations?period={7d,30d,90d}

Computes the in-product wedge metric for Escalation Mode: average / median /
p95 seconds between SessionHandoff.claimed_at and the first ai_session_step
created on the same session after that timestamp. Account-scoped, role-gated
to engineer-or-admin.

The metric is intentionally NOT called "minutes recovered" — that's the
two-metric framing locked by /codex review: this in-product number must be
paired with manual baseline (the verbal-handoff stopwatch from The Assignment)
to produce the savings claim. Schema's `metric_definition` field surfaces the
disclaimer in every response so callers don't oversell it.

Implementation notes:
- Uses correlated scalar subquery for first-step-after-claim per handoff,
  aggregates avg/median/p95 in Python (~1k rows/account/month is well within
  budget; cleaner than percentile_cont gymnastics in SQL)
- Excludes unclaimed handoffs (claimed_at IS NULL)
- Counts claimed-but-no-action handoffs in n_handoffs_claimed but not in
  n_handoffs_with_action — surfaces the conversion-rate signal
- Floors negative deltas at 0 to handle clock-drift edge cases

Tests cover happy path, zero-data, claimed-but-no-action accounting, period
window filtering, multi-handoff aggregation, multi-tenant isolation (Phase 4
RLS landmine pattern), viewer-role 403 gate, and period validation. 9 tests,
all green. No regressions in existing handoff_manager / session_handoffs
suites.

First piece of the Approach A wedge build per
docs/plans/2026-04-27-escalation-mode-wedge-design.md. Unblocks the queue
stat-card and the analytics page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:25:46 -04:00
d51e95cdfa docs(plans): add escalation-mode wedge design + test plan
Captures the GTM thesis, premises, reduced-scope engineering plan, locked UI
specs, and embedded review report for the Escalation Mode wedge — output of
/office-hours, /plan-eng-review, /plan-design-review, and /codex review.

Codex review surfaced two corrections we applied:
- two-metric framing (manual baseline vs in-product time-to-first-action)
- claim role gate moved in-scope (was deferred TODO)

TODO updates: peer-tech escalation + claim role gate captured (the latter then
moved in-scope by the codex pass).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:18:46 -04:00
c0ed6d9840 Merge pull request 'docs(ai): refresh handoff state after PR #153 merge' (#154) from chore/post-153-handoff into main
All checks were successful
CI / frontend (push) Successful in 5m37s
Mirror to GitHub / mirror (push) Successful in 14s
CI / backend (push) Successful in 10m48s
CI / e2e (push) Successful in 11m0s
Reviewed-on: #154
2026-04-26 05:33:31 +00:00
8f818a7c71 docs(ai): refresh handoff state after PR #153 merge
All checks were successful
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 5m49s
CI / backend (pull_request) Successful in 11m5s
CI / e2e (pull_request) Successful in 11m36s
- CURRENT_TASK rolls forward — PR #153 closed out, no active task,
  with recommended next moves (promote e2e gate to required, pick
  from TODO).
- HANDOFF rewritten — new home position is `main`; documents the
  e2e job's stub ANTHROPIC_API_KEY convention so future
  AI-touching e2e tests know what to expect.
- SESSION_LOG entry extended with the CI env-var diagnosis, the
  fix, the merge, and pointers to the natural next pickups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 01:14:49 -04:00
68fcdc6122 Merge PR #153: fix(chat): sync currentChatRef when prefill creates a new chat session
All checks were successful
CI / frontend (push) Successful in 5m57s
Mirror to GitHub / mirror (push) Successful in 13s
CI / backend (push) Successful in 10m28s
CI / e2e (push) Successful in 12m0s
Fixes a silent-drop bug where the dashboard prefill flow created a new chat session but didn't update the in-flight guard ref, so subsequent task-lane submissions had their AI follow-up responses discarded.

Includes a Playwright regression test that drives the prefill flow and stubs /ai-sessions/*/chat to verify the second AI turn renders. Also adds a stub ANTHROPIC_API_KEY to the e2e CI job so AI-gated endpoints clear their _require_ai_enabled() check (the chat call itself is intercepted in the browser, so no real Anthropic traffic).
2026-04-26 05:05:54 +00:00
11fe32f4c6 fix(ci): set stub ANTHROPIC_API_KEY for e2e job so AI-gated endpoints respond
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
CI / frontend (pull_request) Successful in 5m39s
CI / backend (pull_request) Successful in 10m24s
CI / e2e (pull_request) Successful in 12m14s
POST /api/v1/ai-sessions and friends call _require_ai_enabled(), which
returns 503 when no provider key is set. The new prefill-handoff
regression test (e2e/assistant-chat-prefill.spec.ts) drives the
dashboard prefill flow, which has to create a chat session before its
page.route stub on /chat can fire — so without a key, session
creation 503s and the test never sees the task lane.

The Playwright stub intercepts /chat in the browser, so the backend
never actually contacts Anthropic — but the AI-enabled gate still
needs to pass. A stub value is enough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 00:51:39 -04:00
43eed720d9 docs(ai): close out PR #150, set PR #153 as active task
Some checks failed
Mirror to GitHub / mirror (push) Successful in 13s
CI / frontend (pull_request) Successful in 5m50s
CI / e2e (pull_request) Failing after 6m50s
CI / backend (pull_request) Successful in 10m40s
- CURRENT_TASK.md rolled forward — the CI-recovery task is complete
  (PR #150 merged as 87bb20b; backend gate is in required checks).
  Active task is now landing PR #153.
- HANDOFF.md rewritten — new resume point is watching CI on the
  rebased SHA 1559feb and merging when all three checks are green.
- SESSION_LOG.md gains a 2026-04-26 entry covering the prefill bug
  diagnosis, fix, regression test, and the rebase off post-#150 main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 00:30:50 -04:00
1559feb759 docs(ai): track currentChatRef silent-swallow follow-up in TODO
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / frontend (pull_request) Successful in 5m43s
CI / e2e (pull_request) Failing after 6m40s
CI / backend (pull_request) Has been cancelled
The guard pattern that masked the prefill-ref bug fixed in PR #153 is
applied across handleSend, handleTaskSubmit, selectChat, refreshFacts,
refreshActiveFix, and refreshPreview. Worth either logging the
mismatch path or distinguishing expected-stale from unexpected-stale
so the next instance of this class of bug surfaces instead of hiding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 00:24:25 -04:00
b56da2facd fix(chat): sync currentChatRef when prefill creates a new chat session
The dashboard prefill flow in AssistantChatPage set activeChatId after
creating a new session but never updated currentChatRef.current. Every
later handleSend / handleTaskSubmit then tripped the
`currentChatRef.current !== sentForChatId` guard that was supposed to
discard responses for stale chats — and silently dropped the AI's
follow-up. The user saw their submitted message but no assistant
reply, no toast, no task-lane update.

Mirrors what handleNewChat and handleResumeNew already do. Adds an
e2e regression test that drives the dashboard prefill, submits a
partial task-lane response, and asserts the second AI turn renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 00:24:02 -04:00
87bb20b8f0 Merge PR #150: fix(ci): consolidated CI recovery — backend green, xdist parallelization, e2e selector + decoupling
All checks were successful
CI / frontend (push) Successful in 5m42s
Mirror to GitHub / mirror (push) Successful in 13s
CI / backend (push) Successful in 10m21s
CI / e2e (push) Successful in 11m5s
2026-04-25 21:57:26 +00:00
1e3a6cfa01 fix(e2e): harden card selectors for session resume
All checks were successful
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 5m43s
CI / backend (pull_request) Successful in 10m21s
CI / e2e (pull_request) Successful in 11m23s
Co-Authored-By: Codex <noreply@openai.com>
2026-04-25 16:42:33 -04:00
ede6eebf9a docs(ai): note e2e decoupling commit (261814a) in HANDOFF
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / frontend (pull_request) Successful in 5m43s
CI / e2e (pull_request) Failing after 9m30s
CI / backend (pull_request) Successful in 10m18s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:12:19 -04:00
261814ae65 perf(ci): decouple e2e from frontend — build frontend inline in e2e job
Some checks failed
Mirror to GitHub / mirror (push) Successful in 14s
CI / frontend (pull_request) Successful in 5m44s
CI / e2e (pull_request) Failing after 7m42s
CI / backend (pull_request) Successful in 10m28s
Before: e2e \`needs: [frontend]\` waited for the frontend job to upload
a build artifact, then downloaded it. With multiple runners this means
the third runner sat idle for ~6 min while frontend ran, then started
e2e — total wall-clock max(backend, frontend+e2e) ≈ 11 min.

After: e2e builds its own frontend (npm ci + npm run build are already
in the job; just dropped the artifact download step and added the
build). e2e starts immediately on a free runner. Adds ~1-2 min to the
e2e job duration but removes ~5 min of waiting and eliminates the
cross-job artifact mechanism entirely.

Side benefit: no more \`actions/upload-artifact\` v3/v4 GHES headaches
on the cross-job handoff. The \`if: always()\` upload of the
playwright-report at the end of e2e is kept (failure report retrieval
is still useful), but it's a leaf-output, not a dependency.

Net wall-clock: max(backend=9m, frontend=6m, e2e=7m) ≈ 9 min on the
3-runner setup, down from ~11 min.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:59:00 -04:00
6656ebdead docs(ai): reflect PR consolidation — #151/#152 merged into #150
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / e2e (pull_request) Has been cancelled
CI / backend (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:55:08 -04:00
69f2a37591 fix(e2e): update 5 selectors that drifted with FlowPilot/PSA UI changes
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / frontend (pull_request) Successful in 5m52s
CI / e2e (pull_request) Has been cancelled
CI / backend (pull_request) Has been cancelled
Mechanical drift between the e2e selectors and the current UI surfaced
on the first CI run after PR #149 unblocked the artifact upload step.
Five tests, three categories of drift:

1. **Page heading renames** (navigation.spec.ts)
   - `Sessions` → `Session History` on /sessions
   - `Account Settings` → `Account Management` on /account

2. **Route rename** (command-palette.spec.ts:74)
   - The "Troubleshoot with FlowPilot" command palette option now lands
     on /pilot (Phase 1 of the FlowPilot migration renamed /assistant).
     /assistant still 301-redirects, so the assertion accepts either.

3. **Feature moved to /sessions** (history.spec.ts, resume.spec.ts)
   - Default tab on /sessions is "AI Sessions"; flow-session filtering
     and the Resume button moved behind the "Flow Sessions" tab. Both
     tests now click that tab before asserting.
   - resume.spec.ts no longer starts at /trees (Resume buttons aren't
     rendered there anymore — the flow lives on /sessions). Destination
     URL (/trees/:id/navigate) is unchanged.

No product-code changes — these are pure test updates against the
shipped UI. Run the suite locally with
`cd frontend && npm run test:e2e` once a fresh build is available.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:53:57 -04:00
7f714363dd perf(ci): pytest-xdist with per-worker DBs — 22m → ~4m
Backend suite is the slow gate (1076 passed locally in 22m27s on
fix/ci-workflow-config). Adding pytest-xdist with per-worker DB
isolation drops it to ~4m20s on the 8-core homelab runner. Verified
locally: `pytest -n auto --no-cov` finished in 4m28s real time
(15m19s user — confirms ~5× parallelism).

How it works:
- conftest.py reads `PYTEST_XDIST_WORKER` (set per worker by xdist —
  'gw0', 'gw1', …). When set, derives a per-worker DB URL like
  `…/resolutionflow_test_gw0`. The base DB stays for serial / master
  runs.
- `_ensure_worker_db_exists` runs synchronously at conftest import,
  connects to the postgres maintenance DB, and `CREATE DATABASE`s the
  worker-suffixed DB if it doesn't exist. Idempotent across runs.
- The "test" safety guard still applies — every worker DB name
  contains "test" so the assertion holds.
- The per-test `DROP SCHEMA public CASCADE` now operates on the
  worker's isolated DB, no cross-worker race.

CI workflow: backend job switches to `pytest -n auto`. Coverage still
collected (pytest-cov has built-in xdist support).

Adds `pytest-xdist==3.6.1` to requirements-dev.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:53:47 -04:00
1bd43abb8f fix(ci): drop postgres host port mapping (multi-runner port collision)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 6m44s
CI / e2e (pull_request) Failing after 8m43s
CI / backend (pull_request) Has been cancelled
With 3 Gitea Actions runners on the same homelab box, two simultaneous
backend (or backend + e2e) jobs both try to bind 0.0.0.0:5432 for their
postgres service containers. The second fails with:

  failed to set up container networking: ... Bind for 0.0.0.0:5432
  failed: port is already allocated

The host-port mapping isn't actually needed — the workflow uses
\`DATABASE_URL: postgresql+asyncpg://...@postgres:5432/...\` (hostname
\`postgres\` is the service container's docker-network DNS name).
The tests run inside the act container which is on the same docker
network, so they reach postgres without going through the host.

Removing \`ports: 5432:5432\` from both backend and e2e job service
definitions lets multiple postgres services run in parallel on
different docker networks without colliding on the host.

Surfaced when PR #150 ran in parallel with another job after the
multi-runner setup. Backend instant-failed in 2s on the docker run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:28:17 -04:00
c203b70ef9 docs(ai): queue data-testid hardening + reflect PR #152 + 3-runner setup
Some checks failed
CI / backend (pull_request) Failing after 2s
Mirror to GitHub / mirror (push) Successful in 15s
CI / e2e (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
TODO.md: Promote pytest-xdist to  (PR #151 carries it). Adds three new
backlog items:
- data-testid hardening for e2e-critical interactive elements (sparked
  by PR #152's selector drift work)
- per-test transactional rollback (next big speedup if needed)
- pytest-testmon for PR-time test selection

HANDOFF.md: Three open PRs now (#150, #151, #152), all independent.
Three Gitea runner agents now registered, so jobs run in parallel.
Combined with #151's xdist, the prior 1h 14m wall-clock should drop
to ~6-10 min. Updated merge order: #152 first (smallest), #150 next,
#151 last. After all three land, enable CI / backend then CI / e2e
as required status checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:26:21 -04:00
f27e3b44b0 docs(ai): SESSION_LOG entry for the parallelization session
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Successful in 32m33s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Failing after 4m58s
(Was meant to land in fe632c9; the multi-line edit failed silently
because Codex's earlier entry shifted the surrounding context.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:15:41 -04:00
fe632c9194 docs(ai): handoff after CI parallelization + final test fix
Some checks failed
Mirror to GitHub / mirror (push) Has been cancelled
CI / backend (pull_request) Successful in 30m26s
CI / frontend (pull_request) Successful in 5m46s
CI / e2e (pull_request) Failing after 5m3s
Updates HANDOFF.md, CURRENT_TASK.md, and SESSION_LOG.md to reflect:
- PR #150 now contains the AI-provider test mock + caching + maxfail.
  Backend CI should be fully green for the first time in months.
- PR #151 stacked on #150: pytest-xdist with per-worker DBs. Local
  verification: 22m 27s → 4m 28s (5× speedup), 1076 passed both runs.
- DoD is now: merge #150, then #151, then add CI / backend
  (pull_request) to required status checks on main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:15:07 -04:00
e976fb4e87 fix(ci): mock AI provider in record_decision test + cache pip/npm + drop term-missing
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / backend (pull_request) Successful in 31m8s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Failing after 4m57s
Three changes that get PR #150 to a green CI gate:

1. **test_record_decision_persists_and_bumps_state_version** — the
   `decision: draft_template` path calls `_extract_template_parameters`
   (TemplateExtractionService → AI provider). CI doesn't set
   ANTHROPIC_API_KEY/GOOGLE_AI_API_KEY, so the endpoint raised
   `RuntimeError: No AI provider configured` and returned 500. The test
   isn't exercising the AI integration — patched the extractor with an
   AsyncMock returning a minimal valid `{templated_body, parameters}`
   dict. Verified locally: the test now passes.

2. **pip + npm caches** in backend, frontend, and e2e jobs. Keyed on
   the hash of requirements*.txt / package-lock.json with a runner-os
   restore-key fallback. Saves ~30-60s per run on cache hit.

3. **Pytest invocation tightened**:
   - Dropped `--cov-report=term-missing` — the custom "Display coverage
     summary" step below parses coverage.json and prints the same
     module list more concisely. Term-missing dumps every uncovered
     line which adds ~5-10s of stdout.
   - Added `--maxfail=10` so a structural breakage (fixture explosion,
     DB unreachable) bails after 10 errors instead of running the full
     25-min suite. Tunable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:01:05 -04:00
0aefaa78eb docs(ai): queue pytest-xdist parallelization in TODO.md
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / frontend (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / backend (pull_request) Has been cancelled
Capture the backend pytest parallelization work so it survives session
end. Backend suite is currently ~22 min wall-clock for 1076 tests;
xdist with one-DB-per-worker should land in the 3-6 min range on the
homelab Gitea Actions runner.

Also queues two backlog items:
- Frontend lint warnings (23 react-hooks/exhaustive-deps after PR #149)
- Periodic audit of the ResourceWarning filterwarnings added by Codex

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 11:35:38 -04:00
49f88569da wip(handoff): restore backend suite to green
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / backend (pull_request) Failing after 27m35s
CI / frontend (pull_request) Successful in 2m46s
CI / e2e (pull_request) Failing after 4m9s
Co-Authored-By: Codex <noreply@openai.com>
2026-04-25 06:13:23 -04:00
208ec996d5 docs(ai): handoff for Codex — CI recovery + 54 real backend failures
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 28m15s
CI / frontend (pull_request) Successful in 2m55s
CI / e2e (pull_request) Failing after 4m23s
Updates HANDOFF.md, CURRENT_TASK.md, and SESSION_LOG.md so the next
session has accurate resume state. Summary of where things are:

- PR #141 (PSA tickets), PR #147 (FlowPilot Phase 1-9), PR #148 (CI
  fixes part 1), PR #149 (CI fixes part 2) all merged to main in this
  session.
- Branch protection enabled on main: PR-only, CI / frontend required.
- PR #150 (this branch) is the last CI-config PR — adds
  DATABASE_TEST_URL to the workflow and pins upload-artifact to v3.
- Next session: watch #150's CI, merge if green, add CI / backend to
  required checks, then start on the 54 real backend test failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 03:36:54 -04:00
8f7df2c0ef fix(ci): set DATABASE_TEST_URL + downgrade upload-artifact to v3 (Gitea Actions)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 28m29s
CI / frontend (pull_request) Successful in 3m11s
CI / e2e (pull_request) Failing after 4m56s
Two CI-config issues blocking the gate from going green:

1. **Backend tests connect to localhost instead of postgres service.**
   conftest.py reads DATABASE_TEST_URL only — DATABASE_URL is intentionally
   not consulted (per dab740d's test-DB-isolation hardening — running
   pytest with DATABASE_URL set previously dropped the dev DB schema).
   The CI workflow only sets DATABASE_URL, so conftest falls back to its
   localhost default and every fixture-setup fails with
   `OSError: Connect call failed ('127.0.0.1', 5432)` — observed as 638
   errors on the latest main run.

   Add DATABASE_TEST_URL pointing at the postgres service container.
   Same connection string as DATABASE_URL — the test DB and the app DB
   are the same physical postgres in CI; conftest's safety assertion is
   satisfied by the URL containing "test".

2. **Frontend artifact upload fails on Gitea Actions runner.**
   actions/upload-artifact@v4 (and v5) are not supported on Gitea
   Actions / GHES — the runner returns
   `GHESNotSupportedError: ... not currently supported on GHES`. Lint
   itself is now passing (0 errors after PR #149); the job exits 1 only
   because the upload step then fails.

   Pin upload-artifact + download-artifact to v3, the latest version
   compatible with Gitea Actions until they ship v4 support.

After this lands, both backend and frontend CI gates should turn
green — at which point we can also add backend to the required status
checks on main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 03:28:54 -04:00
f27f671fe6 Merge PR #149: fix(ci): frontend lint to zero errors + test-DB schema fix + dev-deps installable
Some checks failed
CI / backend (push) Failing after 10m26s
CI / frontend (push) Failing after 2m35s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 15s
2026-04-25 07:12:15 +00:00
d6218f2e07 fix(tests): import all models in conftest so create_all sees the full schema
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 11m23s
CI / frontend (pull_request) Failing after 2m41s
CI / e2e (pull_request) Has been skipped
The test_db fixture calls Base.metadata.create_all on a fresh test DB.
That only creates tables for models that have been imported (and thus
registered with Base.metadata) by the time the fixture runs.

app.main imports app.core.database (which gives us Base) but does NOT
eagerly import the model modules — most are pulled in lazily inside
scheduler functions (archive_stale_ai_sessions etc.) and route
modules. At fixture-setup time, only the handful of models touched by
those eager imports are on the metadata, so any test that exercises
PSA, network diagrams, ratings, escalations, etc. fails with
\`UndefinedTableError: relation "X" does not exist\` and a cascade of
500s on every endpoint that queries the missing table.

Adding \`from app import models as _models\` (rather than the bare
\`import app.models\` which would shadow the \`app\` FastAPI instance
imported just above) pulls in app/models/__init__.py, which itself
imports every model module — registering all ~60 tables with
Base.metadata before create_all runs.

Verified locally: tests/test_psa_writeback_phase4.py went from
1 failed / 6 errors → 4 failed / 3 passed (the cascading errors were
masking the actual passes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:49:06 -04:00
920a246d77 fix(react): remove four setState-in-effect cascades flagged by react-hooks v5
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 11m23s
CI / frontend (pull_request) Failing after 2m42s
CI / e2e (pull_request) Has been skipped
The new react-hooks lint rule "Calling setState synchronously within an
effect can trigger cascading renders" flagged real anti-patterns in
four spots. Refactored each per the rule's intent (derive during render,
or use useSyncExternalStore for external subscriptions).

1. hooks/useMediaQuery.ts — replaced the useState + useEffect pair with
   useSyncExternalStore. That's the canonical React hook for
   subscribing to external stores (matchMedia in this case) without
   mirroring into local state via an effect. Snapshot/getServerSnapshot
   pair preserves the SSR-safe behaviour.

2. components/network/nodes/DeviceNode.tsx — the prop-sync useEffect
   that copied nodeData.label into labelValue was redundant.
   labelValue is the EDIT BUFFER; while not editing, the displayed
   span now reads nodeData.label directly. The buffer is initialized
   only when an edit session starts (onDoubleClick).

3. components/network/nodes/GroupNode.tsx — same pattern, same fix.

4. components/dashboard/TicketQueue.tsx — the
   setTickets([]) + setLoading(true) + fetchTickets() chain in the
   effect was the cascade. Pushed those writes inside fetchTickets
   (after the function boundary, so they batch with the eventual
   setTickets(result)). Added a request-id ref so a slow first
   response can't overwrite a fast second one.

Frontend lint: 20 errors → 0 errors. tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:33:13 -04:00
b7f8e70be2 fix(lint): replace explicit-any types + unused-expressions ternaries
Five files, all stylistic:

- useFlowPilotSession.ts: typed the axios error shape with a narrow
  inline type instead of \`as any\`.
- FlowPilotSessionPage.tsx: same — typed location.state once, then
  destructured.
- ScriptBuilderTab.tsx: handleViewScript was a placeholder no-op;
  declared the args properly with \`void script; void filename\` so the
  signature matches ScriptBuilderChatProps without no-unused-vars
  firing.
- TicketsPage.tsx: replaced 8 ternaries-as-statements (\`x ? f() : g()\`)
  with proper if/else blocks. Same control flow, satisfies
  no-unused-expressions, and reads better in the URL-param update paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:32:57 -04:00
857d73e3d0 fix(lint): move AssistantSessionRedirect out of router.tsx (react-refresh gate)
react-refresh/only-export-components fires when a file with the
\`router\` const export also defines a component (the redirect helper).
Moves the small helper to its own file under components/routing/ so
HMR can keep the route-component module hot-reload-eligible.

No behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:32:50 -04:00
406ee0ef97 fix(deps): bump pytest 7.4 → 8.4, pytest-cov 4.1 → 5.0 to satisfy pytest-asyncio 0.24
pytest-asyncio==0.24.0 (added on the FlowPilot branch as part of the
RLS test infra refactor) declares pytest>=8.2 — but requirements-dev.txt
still pinned pytest==7.4.3, so a clean pip install fails with
ResolutionImpossible. CI runners that started from a fresh image would
have refused to install dev deps; the FlowPilot tests passed locally
only because the dev container had a pre-installed pytest 8.x lying
around.

pytest-cov 4.1.0 also needs >= 5.0 to play nicely with pytest 8.

No code changes — pytest 8 is API-compatible with the existing test
suite once the install resolves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:32:43 -04:00
32fae2c693 Merge PR #147: feat: FlowPilot migration — Phase 1-9 + Phase 9 bug fixes + QA fixture harness
Some checks failed
CI / backend (push) Failing after 36s
CI / frontend (push) Failing after 1m11s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 11s
2026-04-25 06:02:14 +00:00
a45915fbbc Merge main into feat/flowpilot-migration (PR #148 backports)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 37s
CI / frontend (pull_request) Failing after 1m11s
CI / e2e (pull_request) Has been skipped
Brings PR #148 — two pre-existing CI fixes (network_diagrams JSONB
server_default, removed deprecated session-scoped event_loop fixture).

The conftest.py event_loop fix on main is already incorporated in
FlowPilot's b14a16a (RLS-gating commit, which dropped the same fixture
as part of its larger refactor). Kept HEAD's version of the RLS-gating
collection hook; the event_loop fixture removal is identical.

The network_diagram.py fix lands cleanly via auto-merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:01:46 -04:00
06593a40d9 Merge PR #148: fix(tests): repair two pre-existing bugs blocking backend CI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
2026-04-25 06:01:08 +00:00
9737d90f1b fix(tests): repair two pre-existing bugs blocking the backend CI gate
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 19m36s
CI / frontend (pull_request) Failing after 1m8s
CI / e2e (pull_request) Has been skipped
1. backend/app/models/network_diagram.py — `nodes` and `edges` columns
   used `server_default="'[]'"` (a Python string), which SQLAlchemy
   wraps in single quotes when generating DDL, producing
   `JSONB DEFAULT '''[]'''` — invalid JSON. Switch to
   `server_default=text("'[]'::jsonb")` so the literal is passed through
   and the table can actually be created. Surfaced on every CI run as
   `asyncpg.exceptions.InvalidTextRepresentationError: invalid input
   syntax for type json` at fixture setup time, cascading hundreds of
   test errors.

2. backend/tests/conftest.py — drop the deprecated session-scoped
   `event_loop` fixture. Since pytest-asyncio 0.23+, the plugin manages
   the loop itself; redefining it with a session scope but never
   `set_event_loop()`-ing it left the loop dangling, so any test that
   called `asyncio.run()` (e.g. `test_tasks_are_isolated`) closed the
   process loop and broke the next async test in the module —
   `test_require_tenant_context_raises_403_when_no_account` was the
   visible casualty in the CI logs.

Verified locally:
- `pytest tests/test_uploads.py::test_upload_success` — was setup-error
  on `network_diagrams` DDL; now passes.
- `pytest tests/test_tenant_context.py` — was 1 fail / 3 pass; now 4/4.

Both are real bugs, not test infrastructure churn. Pre-existing on
main; not introduced here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 01:49:50 -04:00
1c904373f8 Merge main into feat/flowpilot-migration
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 36s
CI / frontend (pull_request) Failing after 1m7s
CI / e2e (pull_request) Has been skipped
Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top
of a unified main. Two manual conflict resolutions:

1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven
   protocol). The pre-rewrite reference content (CW integration notes,
   lessons archive, env vars table) lives in `docs/connectwise/`,
   `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design.

2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions
   were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks
   (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with
   PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint).
   Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay,
   NewTicketModal) kept. All setters wired by either branch are intact.

Verification:
- `tsc -b` clean across the merged tree.
- Browser smoke-test (Session B fixture): Phase 9 ProposalBanner
  ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside
  PSA's new Tickets sidebar icon. Console clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 01:03:33 -04:00
16060d2235 Merge PR #141: feat: PSA ticket management — /tickets page, detail panel, AI ticket creation
Some checks failed
CI / backend (push) Failing after 19m11s
CI / frontend (push) Failing after 1m19s
CI / e2e (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 11s
2026-04-25 04:59:02 +00:00
9330ce4782 fix(pilot): two Phase 9 layout/state bugs surfaced by QA fixtures
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
1. EscalateInterceptDialog clipped off-screen.
   The dialog was positioned with `absolute bottom-full mb-2 left-0`
   under the assumption the Escalate button would have room above it.
   In practice the button lives in the chat-page action bar near
   y≈105, so the 302 px dialog overflows the top of the viewport
   and only the last option is visible.

   Switch to `top-full mt-2 right-0` — anchors the dialog below the
   button and aligns its right edge with the button (avoids overflow
   off the right when the button is in the right-side action cluster).

2. TemplateMatchPanel never renders on a fresh session.
   `handleApplyFix` for the script_template_id branch only sets
   `scriptPanelOpen=true`, but TemplateMatchPanel is mounted inside
   `TaskLane.bottomSlot`. On sessions with no questions/facts the
   lane defaults closed, so the panel exists in the React tree but
   inside an unrendered TaskLane — the user clicks Apply fix and
   nothing visibly changes.

   Fix: also `setShowTaskLane(true)` in that branch so the lane
   opens alongside the panel. The ai_drafted_script branch is fine
   (InlineNoTemplateDialog renders in the chat region, not in the
   lane), so it's left alone.

Both bugs were latent — they only surface on sessions that haven't
accumulated TaskLane state yet (questions/facts). Fresh sessions
created from the StartSessionInput hide them because the AI's first
turn populates questions and the lane auto-opens. Caught using the
new seed_phase9_qa_fixtures.py harness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:08:50 -04:00
d68131a865 feat(seed): Phase 9 QA fixture seeder
Adds backend/scripts/seed_phase9_qa_fixtures.py — creates 4 ai_sessions
plus matching session_suggested_fixes that pre-bake the four backend
states the AI orchestrator must produce to mount the five conditional
Phase 9 components:

  A. no template, no draft     → ChatTabStrip + ScriptBuilderTab
  B. ai_drafted_script set      → InlineNoTemplateDialog
  C. script_template_id set     → TemplateMatchPanel
  D. applied_at + status=proposed → EscalateInterceptDialog (verify state)

Background: a Phase 9 QA pass against a regular session left these
five components unreached because the AI didn't emit SUGGEST_FIX in
time/at all. Seeding directly bypasses the AI and lets QA exercise
each surface deterministically.

UUIDs are deterministic (uuid5 over a fixed namespace) so re-runs
upsert. Pass --reset to wipe and recreate. Each session gets two
synthetic conversation messages so the chat header's canAct gate
(messages.length >= 2) opens up Resolve/Escalate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:08:38 -04:00
875bd924a9 fix(pilot): auto-scroll Resolve preview into view when opened
The ResolutionNotePreview popover renders inside TaskLane's
overflow-y-auto region at the bottom of the lane. On a 720px
viewport with the default question/check list expanded, the
popover lands below the visible scroll position — the engineer
clicks "Preview Resolve note", sees the button label flip to
"Showing", but no preview appears on screen.

Add a useEffect that calls scrollIntoView({block: 'nearest'}) on
the popover's outer div whenever `open` flips to true. block:
'nearest' scrolls just enough to make it visible without yanking
the lane to the top.

Discovered during Phase 9 QA. Reproduced at 1280x720; fix verified
visually in the same QA run (screenshots in
.gstack/qa-reports/phase9-*/).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:45:52 -04:00
49c6c8fd00 fix(seed): include cancel_at_period_end in test-user subscription INSERT
Discovered during Phase 9 QA: seed_test_users.py was missing the
cancel_at_period_end column in its subscriptions INSERT, but the
column is NOT NULL (added in 016_add_subscription_tables.py).
Result: seed crashed with NotNullViolationError before any users
were created, blocking auth in fresh dev environments.

Pre-existing on main; not introduced by the FlowPilot migration
branch. Default value: false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:36:04 -04:00
a77e8ea578 chore: bootstrap gstack team mode
Per gstack team-mode install: adds a PreToolUse hook that blocks
skill usage when gstack isn't installed globally, so contributors
are prompted to install it. Un-ignores the two required files
(.claude/settings.json, .claude/hooks/check-gstack.sh) while
keeping settings.local.json and other Claude state ignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:17:06 -04:00
90252bc98f docs(claude-md): expand gstack section with full grouped command list
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:17:01 -04:00
036431aef8 chore(ai): update HANDOFF.md and SESSION_LOG.md for session end
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Reflect current state: dual-agent migration + Codex review round +
branch cleanup (RLS test gating, Phase 9 docs, .remember/ gitignore,
landing-handoff deletion). Working tree clean, no active task, 3
cleanup commits queued to push.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:16:55 -04:00
b3be1e0749 chore: ignore .remember/ skill runtime state
Runtime hook logs and PIDs from the remember skill — local-only, not
repo content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:09:23 -04:00
b3506b5e73 docs(pilot): phase 9 review issues
Review findings companion to docs/FlowAssist_Migration/Issues/phase-8-review-issues.md.
Documents the issues addressed by commit 24972e8 (partial-outcome notes
+ per-fix script-builder remount).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:09:23 -04:00
b14a16a1ab chore(tests): gate RLS tests behind RUN_RLS_TESTS flag
Continues the test-isolation work from dab740d. RLS migration tests run
against a policy-installed database and fail in the default create_all
suite, so they need to be opt-in:

- pytest.ini: register `rls` marker.
- conftest.py: auto-deselect test_rls_isolation.py unless
  RUN_RLS_TESTS=1. Drops the deprecated session-scoped event_loop
  fixture (not needed since pytest-asyncio 0.23+).
- test_rls_isolation.py: tag module with `rls` marker. Replace
  hardcoded `patherly_test` DB reference with parsed DATABASE_TEST_URL
  (matches conftest.py default `resolutionflow_test`). Updated docstring
  command to show RUN_RLS_TESTS=1.
- requirements-dev.txt: bump pytest-asyncio 0.23.0 → 0.24.0 (loop-scope
  marker behavior required by the RLS module fixture).

Run the RLS suite with:
  RUN_RLS_TESTS=1 DB_APP_ROLE_PASSWORD=... pytest tests/test_rls_isolation.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 16:09:13 -04:00
9c8ba296a8 fix(ai): correct stale role-hierarchy and file-listing claims
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Codex review of the dual-agent handoff migration flagged factual errors
carried over verbatim from the pre-migration CLAUDE.md. All claims
verified against the live code before correction.

PROJECT_CONTEXT.md — SaaS shape:
- Role hierarchy was `super_admin > team_admin > engineer > viewer`,
  but `backend/app/core/permissions.py:4` and
  `frontend/src/hooks/usePermissions.ts:4` both define it as
  `super_admin > owner > engineer > viewer`. The `team_admin` concept
  exists separately as an orthogonal team-scoped gate
  (`require_team_admin`, `is_team_admin=True` + valid `team_id`), not
  a level in the primary hierarchy.
- Dep list was missing `require_account_owner` and `require_team_admin`,
  both present in `backend/app/api/deps.py`.

PROJECT_CONTEXT.md — directory tree:
- `api/endpoints/` comment listed 11 routers; `api/router.py` actually
  registers 50+. Replaced with a summary that points at `router.py` as
  the source of truth instead of trying to maintain a freezing list.
- `services/psa/` comment omitted `exceptions.py` and `ticket_context.py`,
  both present in the directory.

CURRENT_TASK.md + TODO.md:
- Replaced `<!-- EXAMPLE -->` placeholders with clearer empty-state
  sentinels so a resume agent sees "no real task yet" at a glance
  rather than placeholder acceptance criteria that look unresolved.

SESSION_LOG.md updated with a follow-up bullet documenting this pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 15:09:22 -04:00
bee8690056 chore(ai): migrate to dual-agent handoff system
Split the monolithic CLAUDE.md into a durable handoff system:

- .ai/PROJECT_CONTEXT.md  — stable architectural truth (stack, structure,
  SaaS shape, ConnectWise, coding standards, frontend patterns, critical
  lessons). Ported verbatim from the previous CLAUDE.md.
- .ai/CURRENT_TASK.md     — single active task with DoD + out-of-scope.
- .ai/HANDOFF.md          — resume point, kept under ~2K tokens.
- .ai/TODO.md             — backlog, read only when CURRENT_TASK complete.
- .ai/DECISIONS.md        — append-only architectural decision log.
- .ai/SESSION_LOG.md      — append-only chronological history.
- .ai/README.md           — human-facing explanation of the system.

Root agent files share a byte-identical protocol block (verified via diff):

- CLAUDE.md — primary agent, with GitNexus + gstack tooling and the
  Claude Opus 4.7 co-author trailer.
- AGENTS.md — OpenAI Codex resume agent, with grep/rg fallbacks and the
  Codex co-author trailer. Steps in when Claude hits session/weekly
  limits.

Legacy root-level SESSION-HANDOFF.md deleted — superseded by .ai/HANDOFF.md.
It was a self-describing one-off from the Design System v4 migration and
had no external references.

Supersedes previous CLAUDE.md. Old version recoverable via
`git show pre-ai-handoff:CLAUDE.md` (tag points at commit e110fed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:50:41 -04:00
995a0c1d2e fix(psa): use schedule entries for ticket co-assignees (CW canonical pattern)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 33s
CI / backend (pull_request) Failing after 17m0s
CI / frontend (pull_request) Failing after 51s
CI / e2e (pull_request) Has been skipped
The previous implementation PATCHed the `resources` string directly, which CW
silently ignores because `resources` is a server-derived read-only field (it's
populated from schedule entries of type/id=4, not freely writable).

Per CW docs (openapi line 70949): "Please use the
/schedule/entries?conditions=type/id=4 AND objectId={id} endpoint".

Behavior per spec:
- No owner + assign user → set owner (existing behavior kept)
- Has owner + assign different user → POST /schedule/entries with type/id=4,
  member, objectId; owner untouched
- User already assigned (owner or schedule entry) → idempotent no-op
- Remove owner → clear owner (existing behavior kept)
- Remove co-assignee → DELETE /schedule/entries/{entry_id}
- list_resources now merges owner + schedule-entry members, deduped by id

Required CW security role permission on the API member:
- Service > Resource Scheduling > Add/Inquire/Delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:34:18 +00:00
f6a24ea4e1 fix(psa): resource assignment targets CW owner, status PATCH verifies apply
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
CI / backend (pull_request) Failing after 15m32s
CI / frontend (pull_request) Failing after 45s
CI / e2e (pull_request) Has been skipped
Previous `resources`-string PATCH was silently ignored by CW — the
`resources` field is server-derived from the ticket's owner + schedule
entries, not freely writable. Status PATCH could also silently no-op
when a cross-board status id was sent.

- add_resource: when the ticket is unassigned, set the `owner`
  MemberReference (the canonical writable primary-assignee field).
  If already owned by someone else, append the identifier to the
  `resources` co-assignee string best-effort.
- remove_resource: clear `owner` (with remove→replace:null fallback) if
  the target is the current owner, otherwise strip from `resources`.
- list_resources: merge owner + resources string, deduped by member id,
  so the UI reflects both single-owner and multi-resource assignments.
- update_ticket_status: verify CW applied the status by comparing the
  response body's status.id — raises PSAError with a clear message when
  CW silently rejects the change (e.g., status invalid for ticket's
  board), instead of reporting spurious success.
- Frontend: surface the backend error detail in the toast so users see
  the real reason instead of a generic "Failed to update" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 21:39:21 +00:00
04ff2ea301 fix(tickets): refresh status and resources in detail panel after update
Some checks failed
Mirror to GitHub / mirror (push) Successful in 3s
CI / backend (pull_request) Failing after 17m32s
CI / frontend (pull_request) Failing after 48s
CI / e2e (pull_request) Has been skipped
Status update was returning only new_status (string) and the parent list's
onStatusUpdated only set status_name. The <select> was bound to status_id,
which never changed — so it visually reverted to the old status even though
the PATCH succeeded.

- Backend: include new_status_id in the status-update response.
- Panel: own currentStatusId/currentStatusName state so the select reflects
  the change immediately and survives stale parent snapshots.
- Parent list: update status_id on both the row and selectedTicket so the
  list row stays in sync when the panel stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:48 +00:00
60851b400a fix(tickets): status filter dropdown and CW resource assignment
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 17m51s
CI / frontend (pull_request) Failing after 52s
CI / e2e (pull_request) Has been skipped
- Status filter: aggregate statuses across all boards (deduped by name)
  when no board is selected. Backend accepts status_name and filters by
  status/name so the same status matches across boards.
- Resource assignment: CW has no /service/tickets/{id}/members endpoint —
  assignees live in the ticket's comma-separated `resources` string field.
  Rewrote list/add/remove to read/PATCH that field via member identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 21:03:00 +00:00
bea34229d6 chore: bump version and changelog (v0.1.0.0)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 18m54s
CI / frontend (pull_request) Failing after 47s
CI / e2e (pull_request) Has been skipped
Add CW security roles reference docs and PSA ticket management plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:44:03 +00:00
294b309faa fix: pre-landing review fixes — company_id filter and CW condition injection
- Apply company_id filter in CW search_tickets conditions (was silently ignored)
- Sanitize query string to strip single quotes before CW condition interpolation
- Add psaError state to TicketsPage for permissions error surfacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:42:05 +00:00
fb7690485b fix(tickets): fix statuses endpoint, members auth gate, and graceful error handling
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
- Add GET /boards/{board_id}/statuses endpoint — direct board-to-statuses lookup
  without ticket roundabout; used by filter bar and new ticket form
- Fix TicketsPage and NewTicketModal to call getBoardStatuses(board_id) instead
  of misusing getTicketStatuses(ticket_id) with a board_id value
- Fix list_members auth: was require_account_owner (owner/super_admin only) —
  changed to require_engineer_or_admin so engineers can see member list for
  ticket assignment
- list_members: return [] on PSAError instead of 502 (Lesson 111 pattern)
- get_ticket_statuses: return [] on PSAError instead of 502

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 05:33:23 +00:00
6044d5a88b fix(tickets): fix permissions toast, board fallback, assignment search, remove load more
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
- list_resources: return [] on PSAError instead of 502 — stops global interceptor
  toast when CW API key lacks ticket members permission (Lesson 111)
- list_boards/list_priorities: add warning logging so Railway logs reveal the
  root cause when CW permissions are missing
- TicketsPage: derive board options from ticket search results when listBoards
  returns empty (CW permissions fallback)
- TicketFilterBar: replace assignment <select> with searchable member picker —
  fixed options (All/Mine/Unassigned) + text-filtered member dropdown
- TicketQueue: remove Load More / infinite scroll; page now exists at /tickets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 04:59:03 +00:00
00cd8b7c55 feat(tickets): update TicketQueue with mapping detection, 5-item cap, View All link
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:42:25 +00:00
fded959b5e fix(tickets): guard linkedTicket fetch with currentChatRef to prevent race condition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:40:31 +00:00
5f5b9e5b23 feat(tickets): add spin-off ticket creation in ResolutionAssist — state, action handler, modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:37:46 +00:00
b2ee1a2150 fix(tickets): improve accessibility and error logging in ticket creation components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:34:08 +00:00
08909aa884 feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:31:21 +00:00
070d2383bc fix: remove unused PSATicketSearchResult import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:26:23 +00:00
d7b1fe6645 feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:24:18 +00:00
a3f8bb3427 feat(tickets): add ticket detail subcomponents
- TicketDetailHeader: Display ticket info with status dropdown
- TicketNotesFeed: Chronological list of ticket notes with internal flag
- TicketAddNote: Form to add notes (requires linked session)
- TicketConfigs: Display related configurations/devices
- TicketRelated: List of related tickets as clickable buttons

All components use type-safe imports from psaContext and integrations APIs.
Styling follows design system (flat dark theme, electric blue accent, Tailwind v4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:19:18 +00:00
f050afc2f7 feat(tickets): add /tickets route and sidebar nav item
Add Tickets page route to router with lazy loading and code splitting.
Add Tickets navigation entry to sidebar in RESOLVE section for both
icon rail and pinned layouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:15:35 +00:00
849e1c16e2 feat(tickets): add TicketsPage with URL-param filter state, stub detail panel and modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:12:26 +00:00
5310cd3fff fix(tickets): add company_id reset to filter clear button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:09:51 +00:00
d2689afa53 feat(tickets): add TicketFilterBar and TicketListRow components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:08:15 +00:00
9d88c8456c feat(tickets): add tickets API client, update integrations API for paginated search, fix callers
- Create frontend/src/api/tickets.ts with ticketsApi (resources, status, create, ai-parse, priorities, search)
- Update integrationsApi.searchTickets and searchTicketsQueue return types from PSATicketSearchResult[] to TicketListResponse
- Fix TicketQueue.tsx to use results.items (append/set) and results.items.length for pagination check
- Fix TicketPickerModal.tsx to use results.items when setting search results
- Export ticketsApi from api/index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:05:13 +00:00
506aac609d feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:02:53 +00:00
7fa81f69a6 feat(psa): add spin-off ticket system prompt rule, backend routing tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:01:21 +00:00
6e0188d0b4 feat(psa): add AI ticket parse endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:59:02 +00:00
24ab1908a6 fix(psa): add TicketListResponseSchema response_model to search_tickets endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:57:23 +00:00
e2cdfac1c3 feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:55:49 +00:00
a5e9615666 feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:52:32 +00:00
66cca70588 feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:50:56 +00:00
e714088a2b feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:49:20 +00:00
ff0ec143e2 feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:45:24 +00:00
8d964e64e4 fix(psa): update autotask/halopsa stub search_tickets return type annotation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:44:08 +00:00
44634b1145 feat(psa): add PaginatedTicketResult type, update provider search_tickets signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:41:48 +00:00
001438008b docs: fix PSA ticket management spec — prefill state, TicketQueue naming
- Replace false claim about linkedTicket state with explicit fetch step on modal open
- Remove MyQueueWidget references; TicketQueue is the existing component being updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:00:33 +00:00
c8b68ad26d docs: fix PSA ticket management spec — pagination source, widget, linked ticket IDs
- Define PaginatedTicketResult provider type + parallel count fetch via CW /count endpoint
- Fix dashboard widget: updates existing TicketQueue (not new), uses searchTicketsQueue
- Fix NewTicketModal prefill: expand PSATicketInfo with company_id/board_id fields
- Correct Dashboard section description: not collapsible, TicketQueue already exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:49:39 +00:00
2b3d52ad77 docs: fix PSA ticket management spec — API contract, actions format, file routing
- Explicitly call out search_tickets breaking change and all existing callers
- Fix [ACTIONS] marker to use JSON array format matching existing parser
- Route system prompt change to assistant_chat_service.py, not flowpilot_engine
- Pivot detail panel hydration to existing getTicketContext + listResources

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:44:34 +00:00
52b369680b docs: add PSA ticket management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:36:27 +00:00
151 changed files with 17038 additions and 789 deletions

47
.ai/CURRENT_TASK.md Normal file
View File

@@ -0,0 +1,47 @@
# CURRENT_TASK.md
**Task:** Add a fourth, non-terminal outcome to the suggested-fix banner — **Awaiting verification** (`applied_pending`). Today the verifying banner forces a synchronous verdict (worked / didn't / partial), but a lot of real fixes are async — engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. Without a fourth state the banner sits stale or the engineer guesses wrong.
**Status:****Engineering complete; PR #156 open.** Backend tests + tsc clean, Alembic migration applies. Pending browser QA.
**Branch:** `feat/fix-pending-verification` (off `main` after the Escalation Mode merge).
**PR:** https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156
## What ships
| Layer | Change |
|---|---|
| Schema | New `FixStatus="applied_pending"` + new `pending_reason` Text column on `session_suggested_fixes`. |
| Migration | `c0f3a4b7e91d` — adds `pending_reason`, extends status CHECK constraint. |
| API | `PATCH /suggested-fixes/{id}/outcome` accepts `applied_pending`, requires `notes`, stamps `applied_at` only (NOT `verified_at`). Pending in/out transitions allowed (parked, like partial). |
| Generators | `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status: resolution note frames the fix as provisional; escalation package surfaces pending verification as the leading hypothesis with reference to what's being waited on. |
| Frontend | New `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`); "Waiting to verify…" overflow option in `VerifyingBanner`; nudge "Still checking" now records pending with a reason instead of just silencing; `AssistantChatPage` banner-mode derivation maps `applied_pending → 'pending'`. |
| Tests | 4 new integration tests in `test_fix_outcome_endpoint.py`: pending-requires-notes, pending stores reason + applied_at-not-verified_at, pending→success transition, pending_reason update on re-PATCH. 21/21 pass. |
## Out of scope (intentionally)
- Cross-session "Follow-ups" dashboard rollup. The chat-anchored `PendingBanner` is the per-session reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions.
- Optional follow-up timer ("remind me in 30m"). Nice but not the wedge.
## Resume point — DO THIS NEXT
**Browser QA:** verify the new flow end-to-end in dev:
1. Trigger a suggested fix, click Apply, then open the verifying banner overflow → "Waiting to verify…" → enter a reason → confirm `PendingBanner` renders with the reason.
2. From `PendingBanner`, click "It worked" → confirm transition to terminal success and banner dismissal.
3. From `PendingBanner`, click "Update reason" → confirm reason updates server-side.
4. Trigger the nudge state (3+ post-apply messages) → click "Still checking" → enter a reason → confirm pending state takes over.
After QA passes, merge PR #156.
## Just-shipped (2026-04-30)
**PR #155 — Escalation Mode wedge** merged into main as `ac42f97`. The wedge for ResolutionFlow's GTM (first paying-customer push). Senior tech sees structured handoff context in seconds via the magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
## Two-metric framing (Escalation Mode — read before quoting numbers)
The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
## Kill-switch (Escalation Mode)
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.

138
.ai/DECISIONS.md Normal file
View File

@@ -0,0 +1,138 @@
# DECISIONS.md
> Append-only architectural decision log. Newest entries at the top.
> Entry format:
>
> ```
> ## YYYY-MM-DD — <short title>
> **Context:** why this came up
> **Decision:** what we chose
> **Rejected:** what we didn't choose and why
> **Consequences:** what this means going forward
> ```
---
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
**Rejected:**
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
**Consequences:**
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
---
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
**Rejected:**
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
**Consequences:**
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
---
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
1. `_build_escalation_package_enhanced` — runs in the background `enrich_escalation_async` task, builds a rich JSON payload that's saved to `ai_session.escalation_package`.
2. `_generate_ai_assessment` — also background, returns the magic-moment screen fields (`likely_cause`, `suggested_steps[]`, `confidence`).
3. `generate_status_update` — engineer-triggered when they click "Ticket Notes" / "Client Update" / "Email Draft" in the conclude modal, generates audience-specific PSA prose.
The user surfaced the smell: the engineer is *typically* generating a status update during the escalate flow, so the AI assessment work is being done twice with overlapping context and the engineer's PSA prose is being thrown away. Live test on 2026-04-29 also showed that bumping the assessment timeout 15s → 45s did NOT fix the empty-placeholder bug — meaning the architectural smell is also a demo blocker.
**Decision:** ONE structured AI call per escalation that produces a single payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Persist to `SessionHandoff`. The conclude modal's "Ticket Notes" button reads from the saved prose instead of calling the model. "Client Update" and "Email Draft" buttons trigger a cheap Haiku transformation over the saved prose (tone shift only, not a re-summarization).
Proposed payload shape (final form decided during implementation):
```json
{
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
"what_we_know": ["<one-liner>"],
"likely_cause": "<one sentence>",
"suggested_steps": ["<short step>"],
"confidence": "low | medium | high",
"audience_variants": {"client_update": null, "email_draft": null}
}
```
`audience_variants` filled lazily on first user request, cached.
**Rejected:**
- **Just bumping the timeout further.** Already tried 5s → 15s → 45s. The architectural redundancy is the real cost — even if Sonnet completed reliably, three calls per escalation is wasteful and creates three places where state can diverge.
- **Reusing the engineer's status update content as the AI assessment.** User's first instinct, but: status updates aren't always generated (engineer has to click), they're audience-specific (so you'd pick which one to copy), and they're prose without the structured fields the magic-moment screen needs. The right consolidation is the OTHER direction — generate ONE structured payload that the status-update buttons consume.
- **Switching the assessment to Haiku for speed.** Faster but solves only the latency symptom, not the redundancy. Doesn't help the conclude modal's status-update buttons.
**Consequences:**
- Magic-moment screen populates in ~5s instead of 25s+ (work happens in the foreground escalate path, not in a background task that races with the senior's pickup).
- Token spend per escalation drops by ~60% — one Sonnet call replaces two; the third (audience variants) becomes Haiku.
- Engineer's "Ticket Notes" button is instant — no model round-trip.
- Schema enforcement matters. The current `_generate_ai_assessment` returns freeform prose that the frontend stuffs into `assessment_text` because the structured fields aren't reliably parseable. The new call must use Anthropic's structured output / tool-use to enforce the schema.
- Migration concern: `ai_session.escalation_package` JSON column has live data on existing sessions. Keep it READABLE for backward compatibility; just stop *writing* the enhanced payload from `enrich_escalation_async`. If downstream queue summaries depend on it, dual-write the basic snapshot.
- Test fixtures (`test_handoff_manager.py`, `test_session_handoffs_api.py`) currently stub `_generate_ai_assessment` via `AsyncMock`. Updating the stubs is part of the rename.
- The frontend SSE assessment-ready subscription (added in `0f00ee5`) stays as-is — it just listens for the new event payload.
---
## 2026-04-28 — Tag the task-lane state with an owner chatId
**Context:** A recurring bug — every time the user returned to test escalation work, creating a new session would flash the previous session's task-lane data (questions, actions, "Tasks" pill counts) before the new session's AI response landed. The first attempt to fix it (`8914391`) added initializer-time guards (`incomingPrefill || isPickup`) that skipped the sessionStorage restore on mount. That covered exactly two entry paths and missed every other case: in-place URL navigation, mid-flight pickup, HMR re-runs, and the gap between `setActiveChatId(B)` and the AI response that finally populates B's questions/actions. The persistence effect made it worse by writing `{chatId: activeChatId, questions: activeQuestions}` — at any moment where activeChatId had flipped before the questions were updated, sessionStorage was stamped with `{chatId: B, questions: [A's data]}` and a subsequent restore would happily render A's data for B.
The root cause was that `activeQuestions` / `activeActions` / `showTaskLane` were three independent state slices implicitly assumed to be in sync with `activeChatId`. The synchronization was by convention, not by structure. Every code path that mutated them had to remember to call `resetSessionDerivedState` first; missing one created stale UI.
**Decision:** Add a `taskLaneOwnerChatId` state that records *which chatId the in-memory questions/actions belong to*, set at every site that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix), cleared in `resetSessionDerivedState`. The persistence effect writes ownerChatId as the chatId tag. Render is gated on `taskLaneOwnerChatId === activeChatId` and ANDed into all three render conditions (toolbar Tasks button, narrow-viewport floating drawer, main side panel). The mount-time `skipTaskLaneRestore` guard stays as belt-and-braces for the prefill/pickup entry-flash window, which the owner-gate alone doesn't cover.
**Rejected:**
- **More entry-path guards.** That's whack-a-mole — the next path nobody anticipated will reproduce the bug. The owner-gate makes the bug structurally impossible regardless of which path triggers it.
- **Combining the four state slices into a single tagged object.** Cleaner long-term but a bigger refactor with more touch points. The owner-tracking approach gets the structural guarantee with a minimal diff and keeps the existing setState patterns.
- **Inlining the comparison at every render site.** Works but proliferates the comparison; one named derived value (`taskLaneIsForActiveChat`) reads better and groups the gate with the persistence-effect / state declarations as a named concept.
**Consequences:**
- Stale task-lane data is structurally unable to display. The lane is hidden during any window where `ownerChatId !== activeChatId`, no matter what mutation path got you there.
- Adding new sites that populate `activeQuestions` / `activeActions` requires also setting `taskLaneOwnerChatId`. The pattern is documented in the commit message and visible in every existing populate site as a paired call.
- The mount-time `skipTaskLaneRestore` guard is now redundant in steady-state but kept for the few-hundred-ms flash window between component mount and the first sendPrefill / selectChat effect. Deleting it would re-introduce a (smaller) flash without strong reason.
- Future task-lane state slices (e.g. `facts`, `activeFix`) follow the same pattern: gate their visibility on the owner check via the existing render conditions. Tagging more slices with their own `*OwnerChatId` is a future refactor if the slices diverge.
---
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.
**Decision:** Split the old CLAUDE.md into `.ai/PROJECT_CONTEXT.md` (stable repo truth), agent-specific root files (`CLAUDE.md`, `AGENTS.md`) with a shared protocol block, and a small handoff toolkit (`CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`). Previous CLAUDE.md snapshotted in commit `e110fed` before the migration.
**Rejected:**
- Single symlinked CLAUDE.md/AGENTS.md — diverges silently, hides agent-specific tooling differences.
- Putting GitNexus/gstack content in AGENTS.md — Codex doesn't have those tools; would mislead the resume agent.
- Keeping the old CLAUDE.md as-is and adding AGENTS.md alongside it — duplicated truth, drift guaranteed.
**Consequences:**
- First read for either agent: `.ai/PROJECT_CONTEXT.md` + `.ai/CURRENT_TASK.md` + `.ai/HANDOFF.md`.
- Architectural changes in the repo require updating PROJECT_CONTEXT.md, not the root agent files.
- Git trailers differ per agent (`Claude Opus 4.7` vs `Codex`) — preserved in each root file.
- Legacy `SESSION-HANDOFF.md` deleted in the same commit; superseded by `.ai/HANDOFF.md`.

49
.ai/HANDOFF.md Normal file
View File

@@ -0,0 +1,49 @@
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
# HANDOFF.md
**Last updated:** 2026-04-30 (session 4 — pending-verification feature shipped, PR #156 open)
**Active task:** Suggested-fix `applied_pending` outcome. Branch: `feat/fix-pending-verification` (1 commit, rebased onto main). PR #156 open, ready for browser QA.
**Just-merged:** PR #155 (Escalation Mode wedge) merged into main as `ac42f97`.
## Where this session ended
Single-PR cleanup pass after Escalation Mode browser QA, then a new feature.
1. **Codex review fixes on the escalation branch** committed as `f10649a` (atomic claim conditional UPDATE, self-claim 403 + queue self-exclusion, preassigned handoff UUID for the compatibility payload, removed legacy `claiming` / `handleStartHere` frontend dead code that broke `tsc --noEmit`).
2. **PR #155 merged** to main via Gitea API (un-drafted, retitled, merged with a merge commit).
3. **New branch `feat/fix-pending-verification`** off main, single commit `00663a4`. Adds `applied_pending` non-terminal status + `pending_reason` column + `PendingBanner` UI + 4 new integration tests. Rebased onto post-merge main.
4. **PR #156 opened** for the new feature.
5. **Cleanup:** 10 stray `core.*` dump files removed from the worktree; merged `feat/escalation-metric-endpoint` deleted locally and on the remote.
**Validation on PR #156:**
- `pytest tests/test_fix_outcome_endpoint.py` ✅ 21/21 pass (including 4 new pending tests).
- `tsc --noEmit -p tsconfig.app.json` ✅ exit 0.
- `alembic upgrade heads` ✅ ran `c0f3a4b7e91d` cleanly.
## Resume point — DO THIS NEXT
**Browser QA on PR #156** (see CURRENT_TASK.md "Resume point" for the 4-step checklist). Then merge.
## Key files changed this session
- `backend/app/models/session_suggested_fix.py` — CHECK constraint extended; `pending_reason` Text column.
- `backend/app/schemas/session_suggested_fix.py``applied_pending` added to both `FixStatus` and `FixOutcome` literals; `pending_reason` on response model; updated docstring on `SessionSuggestedFixOutcomeRequest`.
- `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py` — new migration (rev `c0f3a4b7e91d`, down `71efd2102f49`).
- `backend/app/api/endpoints/session_suggested_fixes.py``patch_outcome` accepts pending, requires notes, stamps applied_at only.
- `backend/app/services/{resolution_note,escalation_package}_generator.py` — system-prompt handling for the new status; `pending_reason` line in input bundle.
- `backend/tests/test_fix_outcome_endpoint.py` — 4 new tests.
- `frontend/src/api/sessionSuggestedFixes.ts` — types updated; `pending_reason` on `SessionSuggestedFix`.
- `frontend/src/components/pilot/ProposalBanner.tsx``'pending'` `BannerMode`; `PendingBanner` component; "Waiting to verify…" overflow option; nudge "Still checking" wired to record pending.
- `frontend/src/pages/AssistantChatPage.tsx` — banner-mode derivation maps `applied_pending → 'pending'`.
## Watch-outs
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works.
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com`, `teamadmin@resolutionflow.example.com`.
- Multi-head alembic state on main is pre-existing (heads `070`, `c0f3a4b7e91d`, `024`); not introduced by this work but worth knowing if `alembic upgrade head` complains — use `upgrade heads` (plural).
- `pending_reason` is preserved as audit trail when an engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional.
- Pre-existing local branches still in the working copy: `chore/post-153-handoff`, `feat/flowpilot-migration`, `fix/ci-*`, `fix/e2e-test-selectors` — left alone.

254
.ai/PROJECT_CONTEXT.md Normal file
View File

@@ -0,0 +1,254 @@
# PROJECT_CONTEXT.md — ResolutionFlow
> SaaS troubleshooting platform for MSPs. Stable architectural truth. Updated only when the repo's shape changes.
---
## Product & naming
Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
---
## SaaS shape
Multi-tenant by account. Primary role hierarchy: `super_admin` > `owner` > `engineer` > `viewer` — driven by `is_super_admin` + `account_role`. Never `role=='admin'` — use `is_super_admin`. Separate team-scoped admin gate exists orthogonally to the role hierarchy: `is_team_admin=True` + valid `team_id`, enforced by `require_team_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`, `require_account_owner`, `require_team_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
---
## Status
Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See [CURRENT-STATE.md](../CURRENT-STATE.md) for live status, [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) for phases.
---
## Tech stack
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
---
## Project structure
```
resolutionflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry
│ │ ├── api/endpoints/ # 50+ routers registered in api/router.py — auth/admin, trees/sessions, AI/chat, scripts, integrations, uploads, accounts, FlowPilot, etc.
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
│ │ ├── api/router.py # registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
│ │ ├── schemas/ # Pydantic
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, exceptions, registry, ticket_context, types)
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
│ │ └── services/knowledge_gap_service.py
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
│ └── tests/ # pytest integration
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios client + endpoint modules
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
│ │ ├── pages/
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
│ │ └── types/
│ └── (Tailwind v4 CSS-only config in src/index.css)
├── docs/plans/archive/ # pre-March 2026 plans
├── docs/connectwise/ # CW API reference + best-practices guides
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
├── .ai/ # dual-agent handoff system (see .ai/README.md)
├── CLAUDE.md · AGENTS.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
```
---
## Dev commands
Full setup in [DEV-ENV.md](../DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
```bash
docker compose -f docker-compose.dev.yml up -d # start stack
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
cd frontend && npm run dev
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
cd backend && alembic upgrade head # migrate
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
cd frontend && npm run build # stricter than tsc --noEmit — final check
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
python -m scripts.seed_trees # seed (from backend/)
```
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
---
## URLs & test users
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
---
## CI
Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
---
## Deployment (Railway)
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
---
## ConnectWise PSA
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
---
## Coding standards
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Commit format: `type: description` (feat/fix/refactor/docs/test/chore). Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly. (Agent-specific `Co-Authored-By` trailers live in CLAUDE.md / AGENTS.md.)
**After shipping:** update [CURRENT-STATE.md](../CURRENT-STATE.md) + [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md), `gh issue close #N` for resolved issues, add lessons only for non-obvious traps (otherwise let the code speak).
---
## Common tasks
- **New endpoint:** `endpoints/``router.py``schemas/` → tests → frontend API client.
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx``AccountLayout` has NO sidebar nav.
---
## Design system
**Source of truth: [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md).** Read before any visual change.
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
- Text: `text-heading``text-primary``text-muted-foreground``text-muted`.
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
- Mockups: `docs/mockups/` (HTML).
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
---
## Frontend patterns
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage``/ai-sessions/{id}/chat``unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
- **Node field priority** (copilot, summaries): `title``question``description``content``label`.
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
---
## Critical lessons
> Lessons 1-40 archived to [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
### Backend / data
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
### AI / FlowPilot
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
### Frontend / UI
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
### Env / infra
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
- **Dev env** — see [DEV-ENV.md](../DEV-ENV.md) for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
---
## Quick reference
| What | Where |
|---|---|
| Detailed status | [CURRENT-STATE.md](../CURRENT-STATE.md) |
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) |
| Design system | [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md) |
| Dev env | [DEV-ENV.md](../DEV-ENV.md) |
| Archived lessons | [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) |
| ConnectWise API | `docs/connectwise/` |
| GitHub issues | `gh issue list --state open` |
| Local API docs | <http://localhost:8000/api/docs> |
| Handoff system | [.ai/README.md](README.md) |

42
.ai/README.md Normal file
View File

@@ -0,0 +1,42 @@
# .ai/ — dual-agent handoff system
ResolutionFlow uses two coding agents: **Claude Code** (primary) and **OpenAI Codex** (resume when Claude hits session or weekly limits). This directory holds the shared state that lets either agent start a session with full context.
## Files
| File | Holds | Written when | Read when |
|---|---|---|---|
| [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) | Stable repo truth: stack, structure, SaaS shape, ConnectWise, coding standards, frontend patterns, critical lessons | Only when the repo's shape changes | Every session start |
| [CURRENT_TASK.md](CURRENT_TASK.md) | The single active task: goal, DoD, assumptions, out-of-scope | On task start; status updates during work | Every session start |
| [HANDOFF.md](HANDOFF.md) | Exact resume point: branch, where you left off, next steps, blockers | On session end / context-window limit | Every session start (most important) |
| [TODO.md](TODO.md) | Backlog of work NOT currently active | When deferring or queueing work | Only when `CURRENT_TASK.md` is `complete` |
| [DECISIONS.md](DECISIONS.md) | Append-only architectural decision log | When an architectural choice is made | Skim top entries each session |
| [SESSION_LOG.md](SESSION_LOG.md) | Append-only chronological history | On session end | Only when broader context is needed |
Agent-specific tooling lives at the repo root:
- [../CLAUDE.md](../CLAUDE.md) — Claude Code's tooling (GitNexus, gstack slash commands, Claude trailer)
- [../AGENTS.md](../AGENTS.md) — OpenAI Codex's tooling (grep/rg fallbacks, Codex trailer)
Both root files contain an **identical shared-protocol block**. If you edit one, edit the other.
## The handoff ritual
At session end (limit hit, task complete, or user stop): update `HANDOFF.md` to reflect the new resume point, update `CURRENT_TASK.md` status if it changed, append to `DECISIONS.md` if you made an architectural call, append a session entry to `SESSION_LOG.md`, and WIP-commit any dirty working tree with `wip(handoff): <one-line>` unless told otherwise. Don't push.
## How to invoke a resume
Tell the agent:
> Read CLAUDE.md (or AGENTS.md) and follow its instructions.
The agent will read its root file, which directs it to `.ai/PROJECT_CONTEXT.md`, `.ai/CURRENT_TASK.md`, and `.ai/HANDOFF.md` before doing anything else.
## Recovery
The previous monolithic CLAUDE.md is recoverable via:
```bash
git show pre-ai-handoff:CLAUDE.md
```
(Tag `pre-ai-handoff` on commit `e110fed` — the snapshot taken before this migration.)

232
.ai/SESSION_LOG.md Normal file
View File

@@ -0,0 +1,232 @@
# SESSION_LOG.md
> Append-only chronological record. Newest entries at the top. Skim when broader context is needed.
> Entry format:
>
> ```
> ## YYYY-MM-DD HH:MM <timezone> — <agent> — <one-line summary>
> - What was accomplished
> - What was left for next session
> - Files touched
> ```
---
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
---
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
- **All 7 QA scenarios passed:**
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
- "I'll take it from here": claim → dismiss overlay → composer focused.
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
- Task lane copy button: toast + checkmark visual feedback.
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
- Backend tests: 17/17 pass.
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
---
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
- **Pickup landed on a blank chat surface.** Root cause: commit `8914391` had made `activeChatId` initialize from `urlSessionId`, which broke the selectChat-gating effect in `AssistantChatPage` (`urlSessionId === activeChatId` short-circuited fresh mounts). Symptom was `selectChat` never firing post-claim; messages, conversation history, and pickup-flow correctness all silently broken.
- **Picked-up session missing from sidebar.** Root cause: `loadChats` runs once at mount; pre-claim the session's `escalated_to_id` is null (the junior didn't specify a target), so `listSessions` doesn't return it. Post-claim `claim_session` sets `escalated_to_id` to teamadmin, but the sidebar list never refreshes.
- Fixes (commit `0d1b305`):
- Replaced the `urlSessionId === activeChatId` gate with a `loadedChatIdsRef` set so selectChat fires once per URL session per page lifecycle, regardless of whether activeChatId already matches.
- Added `loadChats()` call in `handleStartHere` after the claim succeeds so the sidebar reflects ownership.
- Three additional pieces folded into `0d1b305` from the same QA bash:
- **Enter-to-submit on the escalate forms.** Chat-input convention: plain Enter submits, Shift+Enter inserts a newline. Added optional `onSubmit` prop to `RichTextInput` (used by `EscalateModal`) and inline `onKeyDown` on the plain textarea in `ConcludeSessionModal`. The user explicitly asked for this — they want to type the reason and hit Enter without reaching for the mouse.
- **Dashboard `PendingEscalations` rows expand to preview.** Click a row to reveal escalation reason + step count + confidence tier + PSA ticket number. Pick Up button click-stops to still go directly to magic moment. Single expansion at a time.
- **`ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` bumped 15 → 45.** Backend logs showed Sonnet hitting the 15s timeout in field testing. Background-task architecture (e8ba74e) means this no longer blocks the user — only bounds before publishing `has_assessment: false`. **Did NOT fix the live demo.** Assessment placeholder still permanent in user's test.
- Surfaced an architectural smell: the escalation flow makes **three** Sonnet calls — `_build_escalation_package_enhanced`, `_generate_ai_assessment`, and `generate_status_update` (engineer-triggered) — all summarizing the same source material from slightly different angles. User correctly observed: status update is typically generated during the escalate flow anyway; reusing that content would consolidate.
- Decided the right consolidation: ONE structured AI call per escalation that returns both the magic-moment diagnostic fields (`likely_cause`, `suggested_steps[]`, `confidence`) AND PSA-ready prose. Magic moment populates immediately. Status update buttons become tone-shift transformations (Haiku) of the saved prose, not fresh summarizations. Drops to 1 call (~60% token reduction), eliminates the AI-summary placeholder bug because the work happens in the foreground escalate path. Full implementation plan written into CURRENT_TASK.md and DECISIONS.md.
- Session ended pre-consolidation: user is updating Claude Code CLI and starting a fresh session for clean context window. All work pushed to origin (`0d1b305`). PR #155 still draft.
- Test users for the next session (Acme MSP shared account, password `TestPass123!`): `engineer@` (junior) and `teamadmin@` (senior).
- Files touched: `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/components/common/RichTextInput.tsx`, `frontend/src/components/flowpilot/EscalateModal.tsx`, `frontend/src/components/assistant/ConcludeSessionModal.tsx`, `frontend/src/components/dashboard/PendingEscalations.tsx`, `backend/app/core/config.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
## 2026-04-28 02:00 EDT — Claude Code — Plan-locked wedge polish + structural task-lane fix
- Audited `docs/plans/2026-04-27-escalation-mode-wedge-design.md` against the branch and identified four locked-design / Codex-correction items not yet shipped: live AI assessment refresh, suggested-step chips, unread 6px dot on queue cards, and race-condition toast on claim conflict.
- Shipped all four in commit `0f00ee5`:
- **Live AI assessment refresh.** New `HandoffAssessmentReadyEvent` type and `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it tracks a handoff missing its AI assessment; on a matching event it calls `handoffsApi.listHandoffs(sessionId)`, finds the handoff by id, and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e` — without this, the senior had to manually reopen the Context overlay to see the AI assessment when the background task finished.
- **Suggested-step chips.** New `chipsHidden` state in `AssistantChatPage`; chip strip renders above the composer when the magic-moment dissolves and `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty. Click prefills input and focuses; first send via `handleSend` flips `setChipsHidden(true)`; explicit X button also hides. Per-session lifetime by design (Codex correction locked).
- **Unread 6px dot.** localStorage-backed seen set (`rf-escalation-seen`, capped at 200 entries) hydrated in `EscalationQueue`. Card render adds a 6px `bg-accent` dot when not in the seen set. `markSeen` called on Pick Up click AND on card body click (the "open" affordance). Hover deliberately doesn't clear (Codex correction). Pick Up button's onClick now calls `e.stopPropagation()` so it doesn't double-fire the card-open path.
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user` via `selectinload`, rejects different-user re-claims (idempotent for same-user double-clicks), and raises with `claimed_by_id` / `claimed_by_name` / `claimed_at`. The endpoint translates to HTTP 409 with structured `detail = {error: 'already_claimed', claimed_by_id, claimed_by_name, claimed_at}`. `AssistantChatPage.handleStartHere` extracts via `axios.isAxiosError`, formats `"Already claimed by {name} {time_ago}."` using the existing `timeAgo()` helper, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests (`test_claim_session_conflict_raises_already_claimed`, `test_claim_session_idempotent_for_same_user`).
- User then reported that the task-lane stale-flash bug was still happening despite the prior fix `8914391` — "every time we work on something that's related to this, when we go back to test we create a new session and then the task lane shows unrelated session data." The previous fix only covered mount-time entry paths (prefill + pickup); any in-place transition still flashed.
- Shipped structural fix in commit `665530f`. Introduced `taskLaneOwnerChatId` state that explicitly tags which chatId the in-memory `activeQuestions` / `activeActions` / `showTaskLane` values belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix). Cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: taskLaneOwnerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. The lane is structurally unable to display data tagged with a different chat. See DECISIONS entry. **Not yet verified in a real browser** — user is swapping computers and asked for the handoff first.
- The two commits `0f00ee5` and `665530f` are **local-only** at session end. The user did not explicitly authorize a push, so per the handoff rule the branch was left unpushed. First action on resume is `git push`.
- Tests: full handoff + escalation suite (`test_handoff_manager.py`, `test_session_handoffs_api.py`, `test_escalation_bus.py`, `test_flowpilot_analytics_escalations.py`) → 34 passed in 68.89s. Frontend `tsc -b` exit 0 after each commit.
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/types/ai-session.ts`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_handoff_manager.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
## 2026-04-27 22:30 EDT — Claude Code — Escalation Mode: unify /escalate through HandoffManager
- User pushed back on the dual-path proposal: "why would we want two different escalation methods? Should the new one just be the way we escalate regardless if we're using a PSA or not using a PSA?" Right answer. Unified everything through `HandoffManager`.
- Backend changes (commit `029680a`):
- `HandoffCreateRequest` gains optional `target_user_id`; rejects self-targeting.
- `HandoffManager.create_handoff` for intent='escalate' now does what the legacy `flowpilot_engine.escalate_session` used to: sets `session.escalation_reason` and `escalated_to_id`, builds the legacy AI-enhanced `escalation_package` via Sonnet (`_build_escalation_package_enhanced` lazy-imported with graceful fallback), and merges handoff metadata (`intent`, `handoff_id`, `snapshot`, `engineer_notes`) into it. Eager-loads `session.steps` + `session.user` via `selectinload` to dodge async lazy-load `MissingGreenlet` errors.
- New `HandoffManager.finalize_escalation`: generates `SessionDocumentation`, pushes to PSA, and runs `notify()` (bell-icon AppNotification + Slack/Teams external channels) — all pre-commit so persistent state lands atomically with the handoff. Pulls engineer name via a separate User query rather than relying on `session.user` lazy access.
- `dispatch_escalation_notifications` keeps only the fire-and-forget IO (bus publish + per-user emails) post-commit. Found and fixed an in-flight bug: had originally put `notify()` inside dispatch (post-commit), which left `Notification` rows uncommitted — moved into `finalize_escalation` (pre-commit).
- `/handoff` endpoint passes `target_user_id` through and calls `finalize_escalation` pre-commit.
- `/escalate` is now a thin shim: owner-only session lookup → `create_handoff(intent='escalate')``finalize_escalation` → commit → `dispatch_escalation_notifications` → return `SessionCloseResponse`. `flowpilot_engine.escalate_session` is no longer called by any endpoint.
- `pickup_session` accepts both `requesting_escalation` (legacy in-flight) and `escalated` (new canonical) so existing queue items migrate seamlessly.
- Escalation queue list (`/escalation-queue`) and sidebar count match either status.
- Frontend: `useFlowPilotSession` optimistic update flips status to `escalated` instead of `requesting_escalation` so the page state matches the unified backend response.
- Verified end-to-end live against the running dev stack: a single legacy `/escalate` call from `engineer@` produced status=`escalated`, a `SessionHandoff` row (`ea9b375a…`, intent='escalate'), a `SessionDocumentation`, a PSA push attempt (`no_psa` since no ticket), AND an `AppNotification` for `teamadmin@` with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. Backend test suite: `1103 passed in 259.63s` with `-n auto`. Frontend `tsc -b` clean.
- The legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is now effectively dead for any new escalation (magic-moment takes over via the handoff record), but stays in place during the transition for legacy in-flight `requesting_escalation` sessions. Slated for cleanup after pilots run a couple of weeks on the unified path. `flowpilot_engine.escalate_session` is similarly orphaned and can be deleted at the same time.
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/api/endpoints/sidebar.py`, `backend/app/schemas/session_handoff.py`, `backend/app/services/flowpilot_engine.py`, `backend/app/services/handoff_manager.py`, `frontend/src/hooks/useFlowPilotSession.ts`.
## 2026-04-27 21:50 EDT — Claude Code — Escalation Mode: bell-icon notification fix; push + draft PR
- User ran a live escalation test via the EscalateModal (legacy `/escalate` path) and reported that clicking the bell-icon notification "just clears the notification instead of taking me to the session". Diagnosed: navigation IS happening, but the notification link template was `/pilot/{session_id}` without `?pickup=true`, so the senior landed on `FlowPilotSessionPage` with no pickup mode. `loadSession` then hit `GET /ai-sessions/{id}` which 404'd because the senior wasn't owner / `escalated_to_id` / picked-up handler. The user perceived the resulting error state as the action having done nothing.
- Two-part backend fix shipped in `641853a`. (1) `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow (handoff-based or legacy SessionBriefing). (2) `GET /ai-sessions/{id}` access policy: any account member can now read a session's detail when status is `requesting_escalation` or `escalated`. Tenant boundary enforced by RLS — the owner-only guard was overly restrictive for explicitly-shared in-transit states. After-pickup access (handler / `escalated_to_id`) checks still apply for active/resolved sessions.
- Verified end-to-end live: re-login as senior engineer (non-owner, non-target) and `GET /ai-sessions/{escalated-session-id}` returns 200 with full detail. Backend regression with broader subset (`test_escalation_bus`, `test_handoff_manager`, `test_session_handoffs_api`, `test_flowpilot_analytics_escalations`, `test_sessions`, `test_session_sharing`) → 94 passed in 43.26s.
- Pushed `feat/escalation-metric-endpoint` to Gitea. Opened **draft PR #155** against `main` via Gitea API ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Title prefixed `WIP:` so Gitea marks it `draft: true`. PR body links the design + test-plan artifacts and mirrors the test plan as a checklist with visual QA + e2e demo flow as the unchecked items.
- Open question for next session: EscalateModal still calls the legacy `/escalate` endpoint, not the new `/handoff` path. The wedge demo flow (junior escalates → magic-moment renders) is cleaner if EscalateModal goes through `/handoff`. Legacy path does PSA documentation push that the handoff path doesn't, so a parallel path (legacy escalate also creates a handoff record) is probably the right call rather than full migration.
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/services/notification_service.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 21:30 EDT — Claude Code — Escalation Mode: magic-moment handoff-context screen on pickup
- Continued the same session that shipped the live-arrival SSE subscription. Added the magic-moment screen on top.
- New `frontend/src/components/flowpilot/HandoffContextScreen.tsx`: presentational 4-section view (header with problem summary + domain + step count + escalated-time + priority badge; "What's been tried" with engineer notes + step-count affordance; "AI assessment" with likely_cause / suggested_steps / confidence badge; "Start here" CTA). Confidence badge accepts both numeric (0..1) and string ("low"/"medium"/"high") shapes — backend emits the latter, the frontend type says `number`, runtime handles both. Renders an explicit "assessment unavailable — model didn't respond in time" branch when `ai_assessment_data` is null (the 5s timeout from `9bdd995` fired). `prefers-reduced-motion` swaps `animate-slide-up` for `animate-fade-in`. ARIA `role=dialog` + `aria-modal=true` + focus on primary CTA on mount + Esc dismiss when used as a re-openable overlay.
- Integration in `frontend/src/pages/FlowPilotSessionPage.tsx`: on `/pilot/:id?pickup=true`, fetch the handoff list via `handoffsApi.listHandoffs` (account-scoped via RLS, no claim required) and find the latest unclaimed escalate handoff. If found, render the screen and skip `loadSession` (the senior would 404 pre-claim because they aren't yet `escalated_to_id`). "Start here" calls `handoffsApi.claimHandoff`, drops the `?pickup=true` query, and dismisses the screen — the existing `loadSession` effect then fires because the senior is now `escalated_to_id`. New "Context" toolbar button on active sessions (visible only when the senior arrived via the magic-moment flow this session — handoff lookup on demand) re-opens the screen as a dismissible overlay.
- Verified end-to-end against the running dev stack: `listHandoffs` returns the unclaimed handoff with full payload (engineer_notes, snapshot keys); `claimHandoff` flips session status from `escalated``active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds. `tsc -b` exit 0. No backend changes; backend tests still `32 passed in 18.91s`.
- Deferred to TODOs in `CURRENT_TASK.md`: suggested-step chips below the chat input (Codex correction; threads through to `FlowPilotMessageBar`); `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim (today's snapshot is just `problem_summary, problem_domain, status, step_count, confidence_tier`); toolbar "Context" button visibility on revisited active sessions; owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
- Branch state: 3 new commits (`b8627f4` SSE subscription, `f65b657` handoff doc bump, `8e9d22e` magic-moment screen). Branch is unpushed — next session pushes + opens draft PR.
- Files touched this slice: `frontend/src/components/flowpilot/HandoffContextScreen.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 21:00 EDT — Claude Code — Escalation Mode: frontend SSE subscription in EscalationQueue
- Picked up `feat/escalation-metric-endpoint` after the Codex test-stabilization pass. Confirmed green starting state: focused backend subset `32 passed in 18.78s` with `-n auto`.
- Implemented the live-arrival frontend slice. Added `streamEscalations(handlers, signal)` to `frontend/src/api/aiSessions.ts` — fetch-based `ReadableStream` reader (native `EventSource` can't send auth headers) that parses SSE frames (event/data/comment lines), buffers partial frames across chunks, ignores `: keepalive` heartbeats, dispatches `ready` and `handoff_created` events. Added `HandoffCreatedEvent` and `EscalationStreamHandlers` types in `frontend/src/types/ai-session.ts` mirroring the backend bus payload.
- Rewrote `frontend/src/components/flowpilot/EscalationQueue.tsx`. SSE subscription with `AbortController` + exponential-backoff reconnect (1s → 30s cap, attempt counter resets on `ready`). On `handoff_created` the component refetches the queue, diffs against the previous IDs via a `sessionsRef`, prepends new arrivals (newest-first) above established cards (oldest-first preserved). New IDs are tagged for 800ms so the locked 200ms slide-in animation plays before cleanup. Tab-title flash: captures `document.title` at mount, prefixes `(N)` while `document.hidden`, clears on `focus` / `visibilitychange`, restores on unmount. `prefers-reduced-motion: reduce` swaps `animate-slide-in-bottom` for `animate-fade-in`. ARIA: `role="region"` + `aria-live="polite"` on the list, `aria-label="N escalations awaiting pickup"` on the heading; Pick Up button bumped to `py-2.5` to clear the 44px touch floor.
- Verified end-to-end against the running dev stack. `tsc -b` exit 0. Vite HMR'd the new component without errors. Raw SSE handshake against `/api/v1/ai-sessions/escalations/stream` returned 200 with `text/event-stream; charset=utf-8` plus the locked headers (`cache-control: no-cache`, `x-accel-buffering: no`). Subscriber received the `ready` frame on connect; after posting a handoff via the API, the subscriber received the `handoff_created` frame with the full payload — wire format matches the parser exactly. Backend regression: same focused subset still `32 passed in 18.91s`.
- Not yet verified (would need a real browser session): the slide-in animation visually plays, the tab title actually updates, the reduced-motion media-query path, AbortController cancellation on unmount, backoff after a real network blip. Wire contract is confirmed; these are visual/timing-dependent and follow from correct parser + state machine.
- Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) is sitting in the engineer's queue from the verification step. Harmless; useful as visual demo data.
- Left for next session: the magic-moment handoff-context screen — 4 sections (problem summary / what's been tried / AI assessment / Start here CTA), loads on Pick Up, dissolves into the regular FlowPilot session view. Must render gracefully when `ai_assessment` is `None` (per the 5s assessment timeout from Codex's earlier fix).
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/types/ai-session.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-04-27 EDT — Claude Code — Escalation Mode wedge: design through SSE backend (8 commits)
- One long session that produced the entire planning artifact stack and most of the backend for the Escalation Mode wedge. Output of `/office-hours` (8 founder-signal session, top-tier YC archetype indicators), `/plan-eng-review` (scope reduced from "2-3 weeks greenfield" to "~6-9 days integration + metric + polish" once the existing handoff_manager surface was inventoried), `/plan-design-review` (6/10 → 9/10 with magic-moment screen, hero metric placement, and real-time arrival visual locked), and `/codex review` (12 findings, 6 applied — two-metric framing, notification routing, claim auth gate moved in-scope, unread-state fix, "Start here" CTA reframe, per-channel delivery model; 5 rejected including the full-scope reduction Codex pushed for).
- Branched `feat/escalation-metric-endpoint` off `main` @ `c0ed6d9`. Stack at session end: `d51e95c` plan + test-plan artifacts; `52f6d03` `GET /analytics/flowpilot/escalations` endpoint with 9 tests including multi-tenant isolation; `7a5b853` claim-endpoint role gate; `07d0db9` email dispatch on escalate with graceful-degradation regression; `9f0bfd4` `EscalationMetricCard` mounted above the queue list; `a283d0d` mid-flight `.ai/` refresh; `87bd0b7` WIP commit for SSE pub/sub bus + endpoint + 7 bus unit tests + 1 dispatcher integration test + 2 endpoint tests; `ba46fc5` paused-for-Codex-review handoff. Codex picked up from `ba46fc5` and added `bc15952` / `fff8338` / `9bdd995` (test stabilization + assessment latency bound).
- Pause was forced by a runaway local test loop: multiple stale `pytest` processes were left inside `resolutionflow_backend` after several aborted runs and contended on the same Postgres test schema. Codex diagnosed and fixed (see entry above).
- Frontend: thin slice — added `getEscalationMetrics` to `flowpilotAnalyticsApi`, the `EscalationMetricCard` component (loading / error / zero-data states + avg + median + conversion-rate + the inline two-metric disclaimer), and mounted it above `EscalationQueue`. `tsc -b` clean.
- Plan-stage UI decisions locked into the design doc and the codebase: dedicated 4-section magic-moment screen on Pick Up that dissolves into FlowPilot; queue stat-card + dedicated owner analytics page for the hero metric (in two places, not one); 200ms slide-in + tab-title flash on real-time arrival, no sound, respects `prefers-reduced-motion`; unread dot clears on open/claim/dismiss, NOT on hover (Codex correction). Claim role gate moved in-scope per Codex (not deferred to TODO).
- Two TODOs added: peer-tech escalation (deferred to v2 once a pilot asks); mobile/responsive design (also v2; pre-PMF wedge demo targets desktop). Claim role gate's TODO entry was struck through in the same session because it shipped in `7a5b853`.
- Plan and test-plan artifacts copied into `docs/plans/` under the `YYYY-MM-DD-name-design.md` / `-test-plan.md` convention so they live alongside the existing project plans, not just in `~/.gstack/projects/`.
- Left for next session: frontend SSE subscription in `EscalationQueue.tsx` (fetch-based ReadableStream — native EventSource can't send auth headers; match `streamDocumentation` in `frontend/src/api/aiSessions.ts`), then the magic-moment handoff-context screen, then push + draft PR. Default Claude Code model is being switched from Opus 4.7 1M-context to Opus 4.7 (200k) for the next session — the resume docs are sized to be self-sufficient under the smaller window.
- Files touched (committed): `docs/plans/2026-04-27-escalation-mode-wedge-design.md`, `docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`, `backend/app/api/endpoints/flowpilot_analytics.py`, `backend/app/schemas/flowpilot_analytics.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/app/core/escalation_bus.py` (new), `backend/tests/test_flowpilot_analytics_escalations.py` (new), `backend/tests/test_escalation_bus.py` (new), `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/types/flowpilot-analytics.ts`, `frontend/src/api/flowpilotAnalytics.ts`, `frontend/src/components/flowpilot/EscalationMetricCard.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/EscalationQueuePage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/TODO.md`.
## 2026-04-27 19:50 EDT — Codex — Stabilize Escalation Mode SSE backend tests
- Diagnosed slow backend tests on `feat/escalation-metric-endpoint`. Multiple stale pytest processes were still alive inside `resolutionflow_backend` and held `resolutionflow_test` transactions open, blocking later per-test schema resets on `DROP SCHEMA public CASCADE`.
- Reproduced a deterministic hang in `test_escalations_stream_returns_sse_content_type`: HTTPX `ASGITransport` buffers the full response body before returning, so an infinite SSE response never yielded the initial chunk and kept the auth DB dependency transaction open.
- Fixed `stream_escalations` to release auth dependencies before the long-lived stream body with `Depends(..., scope="function")`.
- Reworked the SSE handshake test to call `stream_escalations()` directly and consume one generator yield, then close it; kept viewer role-gate coverage through the API client.
- Stubbed `_generate_ai_assessment()` in handoff manager/API tests so escalation handoff tests no longer wait on the real AI path.
- Normalized account IDs inside `EscalationBus` so string UUIDs and `UUID` objects hit the same subscriber bucket; added a regression test.
- Verified focused backend subset: serial `31 passed in 46.95s`; xdist `31 passed in 17.80s`. Confirmed no lingering pytest processes or test DB sessions afterward.
- Follow-up in the same session: fixed the product latency risk by adding `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` (default 5s) around escalation AI assessment generation. If the optional assessment times out, handoff creation continues with no assessment. Added regression coverage; focused xdist subset now `32 passed in 17.77s`.
- Left for next session: continue frontend SSE subscription in `EscalationQueue.tsx`, then the magic-moment handoff-context screen.
- Files touched: `backend/app/api/endpoints/session_handoffs.py`, `backend/app/core/config.py`, `backend/app/core/escalation_bus.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_escalation_bus.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/TODO.md`.
## 2026-04-26 03:50 EDT — Claude Code — Ship AssistantChatPage prefill `currentChatRef` fix; close out PR #150
- User reported a troubleshooting-session bug: after answering a subset of task-lane questions and clicking *Send N of M Responses*, no AI response appeared. Traced to `AssistantChatPage`: the dashboard prefill effect set `activeChatId` after creating a new chat session but never updated `currentChatRef.current`. The `currentChatRef.current !== sentForChatId` guard in `handleSend` and `handleTaskSubmit` then bailed silently on every later request and discarded the AI's reply. The user message was already pushed to the chat before the await, so the user saw their answers but nothing else.
- Fix: one-line addition mirroring `handleNewChat` and `handleResumeNew` — assign `currentChatRef.current = session.session_id` immediately after `setActiveChatId(session.session_id)` in the prefill effect. Branched off `origin/main` as `fix/tasklane-prefill-ref`; PR #153 opened on Gitea.
- Authored a Playwright regression test `frontend/e2e/assistant-chat-prefill.spec.ts` that drives the real dashboard prefill flow against the real backend, stubs `/ai-sessions/*/chat` with `page.route` for deterministic turn-1/turn-2 responses, and asserts the second AI message renders. Confirmed the test fails on unfixed code at the exact assertion (`Got it — based on your answer…` never appears) and passes once the fix is restored.
- Verified locally inside `mcr.microsoft.com/playwright:v1.58.2-noble` against the running dev stack: new spec passes, adjacent `flowpilot-chat` spec still passes, `tsc -b` clean. `resume.spec` and `history.spec` failures observed are pre-existing real-backend fixture collisions, unrelated to this change.
- First CI run on PR #153 failed on infrastructure issues already addressed by PR #150: backend hit `Bind for 0.0.0.0:5432 failed: port is already allocated`, frontend hit `actions/upload-artifact@v4 not supported on GHES`. PR #150 was already merged (commit `87bb20b` on `main`). Rebased `fix/tasklane-prefill-ref` onto new `main` (force-push `1a8cb06``1559feb`), resolved a `.ai/TODO.md` conflict by keeping both backlog item sets, kicked off CI on the rebased SHA.
- Confirmed `CI / backend (pull_request)` is now in branch protection's required-status-checks list (added during PR #150 close-out). `CI / e2e (pull_request)` left as not-required pending one more clean PR run as the threshold.
- Recorded the broader silent-return concern in TODO backlog: the `currentChatRef.current !== sentForChatId` guard is applied across `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview`. PR #153 fixes one symptom but the same pattern can mask other drift. Either log a Sentry breadcrumb on the mismatch path or distinguish "expected stale" (chat switch) from "unexpected stale" (ref never updated) so the latter alerts.
- First CI run on the rebased SHA passed backend and frontend but failed e2e: the new prefill regression test couldn't render the task-lane question text. Diagnosed via the job log: `POST /api/v1/ai-sessions` calls `_require_ai_enabled()` and returns 503 when no provider key is set. The e2e CI job had neither `ANTHROPIC_API_KEY` nor `GOOGLE_AI_API_KEY` in env. Locally the dev backend has a real key, hence the local pass. The Playwright `page.route` stub on `/chat` was correct but never had a chance to fire because the upstream session-creation call was 503-ing.
- Fix: added a stub `ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests` to the e2e job env in `.gitea/workflows/ci.yml`. The Playwright stub still intercepts the actual `/chat` call in the browser, so the backend never contacts Anthropic — the gate just needs to clear. Documented the convention in a workflow comment so future AI-touching e2e tests know what to expect. Pushed `11fe32f`; CI went all-green.
- Merged PR #153 as `68fcdc6` on `main`. Local feature branch and remote both deleted via Gitea's `delete_branch_after_merge`.
- Opened a small follow-up `chore/post-153-handoff` PR to refresh the now-stale `.ai/` files (this entry, plus `CURRENT_TASK.md` rolling forward to "no active task — pick from `TODO.md`" and `HANDOFF.md` updating to the post-merge home position). The `data-testid` audit at the top of `TODO.md` "Up next" or the `currentChatRef` silent-return audit added in this session's backlog are the natural next pickups.
- Files touched: `frontend/src/pages/AssistantChatPage.tsx` (the one-line fix + comment), `frontend/e2e/assistant-chat-prefill.spec.ts` (new regression test), `.gitea/workflows/ci.yml` (stub `ANTHROPIC_API_KEY` for e2e), `.ai/TODO.md` (silent-return follow-up entry, plus conflict resolution preserving PR #150's backlog additions), `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` (this entry).
## 2026-04-25 16:41 EDT — Codex — Stabilize PR #150 e2e selectors
- Investigated the remaining PR #150 failure after backend and frontend CI were green. The e2e resume smoke test was not failing because of product behavior; it used `.bg-card` plus text filtering and matched the tree filter `<select>` before the intended session card.
- Added stable test IDs to flow session, tree, and share cards, then updated affected e2e tests to target those cards instead of Tailwind class names.
- Hardened the CI workflow by making Postgres healthchecks authenticate as `postgres` and baking `VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}"` into the e2e frontend build.
- Verified with `git diff --check`, frontend build in Docker, no remaining `.bg-card` e2e selectors, and focused Playwright runs in an Actions-like Ubuntu container: resume spec passed, then history/library/library-start/resume/shares passed (`6 passed`).
- Left for next session: push this WIP commit to PR #150, watch CI, merge when all three jobs are green, then enable backend branch protection and consider the e2e gate after a reliable green run.
- Files touched: `.gitea/workflows/ci.yml`, `frontend/e2e/history.spec.ts`, `frontend/e2e/library-start.spec.ts`, `frontend/e2e/library.spec.ts`, `frontend/e2e/resume.spec.ts`, `frontend/e2e/shares.spec.ts`, `frontend/src/components/library/TreeGridView.tsx`, `frontend/src/components/library/TreeListView.tsx`, `frontend/src/pages/MySharesPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
## 2026-04-25 12:00 America/New_York — Claude Code — Mock final AI-provider test, cache CI deps, parallelize backend with pytest-xdist
- Diagnosed why CI was still red despite Codex's local 1076 passed: a single test (`test_record_decision_persists_and_bumps_state_version`) needed `ANTHROPIC_API_KEY` because the `decision: draft_template` path calls `TemplateExtractionService` → AI provider. Patched `_extract_template_parameters` with an `AsyncMock` so the test no longer depends on AI availability. Verified.
- Pushed Codex's WIP commit `49f8856` to PR #150 (had been local-only per handoff protocol).
- PR #150 (`fix/ci-workflow-config`) extended with cheap CI wins: `actions/cache@v3` for pip + npm in all three jobs; dropped `--cov-report=term-missing` (the custom display step parses JSON); added `--maxfail=10` so structural breakage exits fast.
- PR #151 (`fix/ci-pytest-xdist`) opened, stacked on #150: pytest-xdist with per-worker DB isolation. `conftest.py` reads `PYTEST_XDIST_WORKER`, computes a per-worker DB URL like `…_gw0`, and synchronously CREATEs the DB on first import. The per-test `DROP SCHEMA public CASCADE` then operates on the worker's isolated DB. Verified locally: backend suite went from 22m 27s serial → 4m 28s parallel (8 workers), 1076 passed in both cases. ~5× speedup.
- Decided NOT to do per-test transactional rollback (bigger refactor); captured for future TODO consideration.
- Left for next session: watch CI on both PRs, merge in order (#150 first, #151 second), then enable `CI / backend (pull_request)` as a required status check on main.
- Files touched: `backend/tests/test_session_suggested_fixes_api.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/TODO.md`.
## 2026-04-25 06:12 EDT — Codex — Fix backend suite to green
- Fixed the real backend failures left after the CI-infra cleanup: tenant-scoped seed drift, missing production `account_id` writes, public route mounting for survey/share links, Script Builder library saves, resolution output async loading, AI search schema metadata, disabled-AI fixture leakage, and prompt marker guardrails.
- Added backend CI/dev system packages required by WeasyPrint PDF export.
- Stabilized the pytest harness for pytest-asyncio/asyncpg teardown ResourceWarnings under `filterwarnings = error`.
- Verified `pytest --override-ini="addopts=" -q` inside `resolutionflow_backend`: `1076 passed, 35 deselected in 1347.41s`.
- Left for next session: commit/push if needed, check and merge PR #150 when Gitea CI is green, add backend CI as a required branch-protection check, and rerun frontend lint if final DoD requires it.
- Files touched: `.gitea/workflows/ci.yml`, `backend/Dockerfile.dev`, `backend/app/api/endpoints/folders.py`, `backend/app/api/endpoints/script_builder.py`, `backend/app/api/endpoints/shares.py`, `backend/app/api/router.py`, `backend/app/models/ai_session.py`, `backend/app/schemas/user.py`, `backend/app/services/assistant_chat_service.py`, `backend/app/services/resolution_output_generator.py`, `backend/app/services/script_builder_service.py`, `backend/pytest.ini`, `backend/tests/conftest.py`, and focused backend tests.
## 2026-04-25 02:00 America/New_York — Claude Code — Land FlowPilot + PSA, recover CI from 488 errors to ~4
- Started session by completing pending FlowPilot Phase 9 QA: ran `/qa` against the seeded fixtures, found and fixed four latent layout/state bugs (`ResolutionNotePreview` off-screen, `TemplateMatchPanel` deadlock when TaskLane closed, `EscalateInterceptDialog` clipped above viewport, `seed_test_users.py` `cancel_at_period_end` NOT NULL crash). Added a new fixture seeder `backend/scripts/seed_phase9_qa_fixtures.py` that pre-bakes the four backend states the AI orchestrator needs to emit, so future QA can exercise all 7 conditional Phase 9 components without depending on stochastic AI behavior.
- Discovered PR #141 (PSA ticket management) and `feat/flowpilot-migration` had 5 overlapping files but only 2 real conflicts (`CLAUDE.md`, `AssistantChatPage.tsx`). Conflicts were both additive — concatenated rather than chose-a-side.
- Merged PSA first (PR #141), then merged FlowPilot (PR #147), each through Gitea API. `tsc -b` clean and visual smoke-test confirmed PSA's Tickets sidebar coexists with Phase 9 ProposalBanner.
- Discovered main had been merging through a broken CI gate for several merges. Initially recommended "stop the line, fix CI before shipping." After scoping the actual rot (~50% of tests red, ~600 errors on a clean run), reversed the recommendation: ship the queue first because FlowPilot itself carried significant test-infra repairs that would be duplicated work on a fresh recovery branch.
- PR #148: two surgical fixes to main (network_diagrams JSONB `server_default` triple-quote bug, deprecated session-scoped `event_loop` fixture in conftest). +78 passing / -114 errors.
- PR #149: frontend lint `20 errors → 0`, `requirements-dev.txt` pytest pin bumped to satisfy `pytest-asyncio==0.24.0`'s `pytest>=8.2`, and a one-line `from app import models as _models` in conftest that registers all ~60 models with `Base.metadata` before `create_all`. The conftest fix collapsed 484 of the remaining 488 backend errors. `1018 passed / 4 errors / 54 failed` after.
- Enabled Gitea branch protection on `main`: PR-only merges, `CI / frontend (pull_request)` required, force-push blocked, no review required.
- Discovered CI on the merge commit STILL showed red despite local pytest being mostly green. Root cause: workflow only set `DATABASE_URL`, but conftest reads only `DATABASE_TEST_URL` (per `dab740d`'s safety hardening). 638 connection-refused errors on every fixture setup. Plus `actions/upload-artifact@v4` not supported by Gitea Actions. PR #150 fixes both.
- Left for next session: merge PR #150 once CI confirms green, add `CI / backend (pull_request)` to required status checks, then root-cause and fix the 54 real backend test failures (one sample seen — `test_user` fixture leaking across calls causing duplicate-email violations).
- Files touched (committed): `backend/scripts/seed_test_users.py`, `backend/scripts/seed_phase9_qa_fixtures.py` (new), `backend/app/models/network_diagram.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `frontend/src/components/pilot/ResolutionNotePreview.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/components/pilot/ScriptBuilderTab.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `frontend/src/pages/TicketsPage.tsx`, `frontend/src/hooks/useFlowPilotSession.ts`, `frontend/src/hooks/useMediaQuery.ts`, `frontend/src/components/dashboard/TicketQueue.tsx`, `frontend/src/components/network/nodes/DeviceNode.tsx`, `frontend/src/components/network/nodes/GroupNode.tsx`, `frontend/src/components/routing/AssistantSessionRedirect.tsx` (new), `frontend/src/router.tsx`, `.gitea/workflows/ci.yml`, `.claude/settings.json` (new), `.claude/hooks/check-gstack.sh` (new), `.gitignore`, `CLAUDE.md`, `.gstack/qa-reports/phase9-*/` (QA artifacts).
- Net merges to main: PR #141 (PSA), PR #147 (FlowPilot), PR #148 (CI fixes part 1), PR #149 (CI fixes part 2). PR #150 still open at session end.
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
- Seeded `CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`.
- Deleted legacy `SESSION-HANDOFF.md` (superseded).
- Left for next session: first real feature task should replace the seed `CURRENT_TASK.md` and update `HANDOFF.md` with real resume state.
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.

23
.ai/TODO.md Normal file
View File

@@ -0,0 +1,23 @@
# TODO.md
> Backlog of work NOT currently active. Read only when `CURRENT_TASK.md` status is `complete`.
> Format: `- [ ] short description — optional link to issue/PR`
## Up next
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
## Backlog
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions``Session History`, `Account Settings``Account Management`, `/assistant``/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
- [ ] **AssistantChatPage `currentChatRef` guard is a silent return**`handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.
- [ ] **Allow peer-tech to escalate a colleague's session.** Today `POST /ai-sessions/{session_id}/handoff` in [endpoints/session_handoffs.py:48](backend/app/api/endpoints/session_handoffs.py#L48) filters by `AISession.user_id == current_user.id`, so only the session owner can escalate. Real MSP shops have peer hand-offs: Junior A is on lunch, Junior B sees the session is stuck and should be able to escalate it. Auth tweak: switch from session-owner check to `require_engineer_or_admin` + same-account scope. Add a `handed_off_by` audit column (already exists on `SessionHandoff`) so the original-owner-vs-actual-escalator distinction is preserved. Surfaced from /plan-eng-review on the Escalation-Mode wedge plan; v1 wedge demo doesn't need this (solo-founder pilot), but capture for v2 once 3+ pilots are live and a peer-claim need surfaces.
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO.

20
.claude/hooks/check-gstack.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Block skill usage when gstack is not installed globally.
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
cat >&2 <<'MSG'
BLOCKED: gstack is not installed globally.
gstack is required for AI-assisted work in this repo.
Install it:
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
Then restart your AI coding tool.
MSG
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
exit 0
fi
echo '{}'

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
}
]
}
]
}
}

View File

@@ -17,10 +17,13 @@ jobs:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
ports:
- 5432:5432
# No host port mapping. Tests connect to `postgres:5432` (the service
# container's docker-network DNS name), not `localhost:5432`. With
# multiple Gitea runners on the same homelab box, host-port mapping
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
# second fails with "port is already allocated".
options: >-
--health-cmd pg_isready
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
@@ -28,6 +31,12 @@ jobs:
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
# not consulted after the dab740d test-isolation hardening). The CI test
# DB is the same postgres service, so point DATABASE_TEST_URL at it
# explicitly — without this, conftest falls back to localhost:5432 and
# all tests fail at fixture setup with "connection refused".
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
@@ -37,6 +46,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install system dependencies
run: |
apt-get update
apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev
- name: Install dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
@@ -47,7 +69,15 @@ jobs:
run: cd backend && python scripts/check_tenant_filters.py
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
# `-n auto` parallelizes across all runner cores via pytest-xdist.
# conftest.py creates a per-worker DB (resolutionflow_test_gw0,
# resolutionflow_test_gw1, …) so the per-test DROP SCHEMA doesn't
# race across workers. Master/serial runs keep the base DB.
# term-missing dropped — the custom "Display coverage summary" step
# below parses coverage.json and prints the same info more concisely.
# --maxfail=10 short-circuits on structural breakage so we don't burn
# 25 minutes when a fixture explodes.
run: cd backend && python -m pytest --override-ini="addopts=" -n auto --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
- name: Display coverage summary
if: always()
@@ -75,6 +105,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: cd frontend && npm ci
@@ -87,15 +125,14 @@ jobs:
- name: Build
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist
retention-days: 1
# Build artifact intentionally NOT uploaded. The e2e job below builds
# its own frontend rather than downloading one from this job, so there
# is no need for the cross-job artifact handoff (which previously broke
# on actions/upload-artifact@v4 GHES support and forced a v3 pin).
# Decoupling also lets e2e start immediately rather than waiting for
# this job to finish — important on a multi-runner setup.
e2e:
needs: [frontend]
runs-on: ubuntu-latest
services:
@@ -105,10 +142,13 @@ jobs:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: resolutionflow_test
ports:
- 5432:5432
# No host port mapping. Tests connect to `postgres:5432` (the service
# container's docker-network DNS name), not `localhost:5432`. With
# multiple Gitea runners on the same homelab box, host-port mapping
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
# second fails with "port is already allocated".
options: >-
--health-cmd pg_isready
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
@@ -121,21 +161,45 @@ jobs:
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
# AI-touching endpoints (POST /ai-sessions, /chat, /respond, etc.) are
# gated by `_require_ai_enabled()`, which returns 503 when no provider
# key is set. Tests that exercise those flows stub the AI calls in the
# browser via `page.route`, so the backend never actually contacts
# Anthropic — but the gate still has to pass. A stub value is enough.
ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests
steps:
- uses: actions/checkout@v4
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install backend dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Download frontend build
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: frontend/dist
- name: Build frontend
# Building inline (instead of downloading an artifact from the
# frontend job) drops the cross-job dependency, so e2e can start
# immediately on a free runner. Adds ~1-2 min of build time, but
# eliminates the artifact-upload mechanism entirely (no more
# v3/v4 GHES headaches) and saves ~5 min of waiting.
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}" npm run build
- name: Install Playwright browser
run: cd frontend && npx playwright install --with-deps chromium
@@ -145,7 +209,7 @@ jobs:
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |

9
.gitignore vendored
View File

@@ -207,7 +207,11 @@ marimo/_lsp/
__marimo__/
# Claude Code (local config, agents, settings)
.claude/
.claude/*
!.claude/settings.json
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/check-gstack.sh
.agents/
# Database dumps
@@ -238,3 +242,6 @@ package-lock.json
# graphify knowledge graph outputs
graphify-out/
.graphify_python
# remember skill runtime state (hook logs, PIDs)
.remember/

61
AGENTS.md Normal file
View File

@@ -0,0 +1,61 @@
# AGENTS.md — ResolutionFlow
You are OpenAI Codex, the resume agent for ResolutionFlow. Claude Code is the primary coding agent; you step in when Claude hits session or weekly limits.
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
> The protocol section below is byte-identical to the shared block in CLAUDE.md. If you edit one, edit the other.
## Shared protocol
### Startup ritual (every session)
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
3. Read `.ai/HANDOFF.md` — exact resume point.
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
5. Run `git log --oneline -15` and `git status`.
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
### Handoff ritual (session end — limit hit, task complete, or user stop)
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
2. If `CURRENT_TASK.md` status changed, update it.
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
4. Append a session entry to `.ai/SESSION_LOG.md`.
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
### Writing rules for .ai/ files
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
### Project principle
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
## Codex-specific notes
### Tooling you do NOT have
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow.
- **No `/codex` second-opinion command.** You are Codex.
### Git trailer
Every commit: `Co-Authored-By: Codex <noreply@openai.com>`
### Model selection
Handled on OpenAI's side. Do not attempt to set Anthropic model aliases for your own runtime. (The repo's application code still uses Anthropic aliases like `claude-sonnet-4-6` via `settings.get_model_for_action()` — that's runtime config for the product, not your agent.)
### Reviewing Claude's work
When you resume from a Claude session, assume some decisions may have been informed by GitNexus queries or gstack commands whose output isn't in the handoff. If a decision looks unverified from the `.ai/` files alone, either:
- re-verify with `grep`/`rg`/file reads, or
- flag it in `HANDOFF.md` under "Open questions" so Michael or Claude can confirm on the next handoff.
Do not assume tooling output that isn't written down.

View File

@@ -2,6 +2,30 @@
All notable changes to ResolutionFlow are documented here.
## [0.1.0.0] - 2026-04-16
### Added
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
### Fixed
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
- Assignment search and "Load More" removed from resource manager in favor of direct member list
## [Unreleased]
### Added

267
CLAUDE.md
View File

@@ -1,215 +1,43 @@
# CLAUDE.md — ResolutionFlow
> SaaS troubleshooting platform for MSPs. Last reviewed 2026-04-19.
You are Claude Code, the primary coding agent for ResolutionFlow. OpenAI Codex is the resume agent when you hit session or weekly limits.
**Naming:** Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
> The protocol section below is byte-identical to the shared block in AGENTS.md. If you edit one, edit the other.
**SaaS shape:** Multi-tenant by account. Roles: `super_admin` > `team_admin` > `engineer` > `viewer`. Team admin = `role='engineer'` + `is_team_admin=True` + valid `team_id`. Never `role=='admin'` — use `is_super_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
## Shared protocol
**Status:** Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See `CURRENT-STATE.md` for live status, `03-DEVELOPMENT-ROADMAP.md` for phases.
### Startup ritual (every session)
**Principle:** Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
3. Read `.ai/HANDOFF.md` — exact resume point.
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
5. Run `git log --oneline -15` and `git status`.
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
---
### Handoff ritual (session end — limit hit, task complete, or user stop)
## Tech stack
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
2. If `CURRENT_TASK.md` status changed, update it.
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
4. Append a session entry to `.ai/SESSION_LOG.md`.
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
### Writing rules for .ai/ files
---
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
## Project structure
### Project principle
```
resolutionflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry
│ │ ├── api/endpoints/ # auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations, flow_proposals, flowpilot_analytics
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
│ │ ├── api/router.py # registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
│ │ ├── schemas/ # Pydantic
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
│ │ └── services/knowledge_gap_service.py
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
│ └── tests/ # pytest integration
├── frontend/
│ ├── src/
│ │ ├── api/ # Axios client + endpoint modules
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
│ │ ├── pages/
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
│ │ └── types/
│ └── (Tailwind v4 CSS-only config in src/index.css)
├── docs/plans/archive/ # pre-March 2026 plans
├── docs/connectwise/ # CW API reference + best-practices guides
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
├── CLAUDE.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
```
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
---
## Claude-specific tooling
## Design system
**Source of truth: [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md).** Read before any visual change.
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
- Text: `text-heading``text-primary``text-muted-foreground``text-muted`.
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
- Mockups: `docs/mockups/` (HTML).
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
---
## ConnectWise PSA
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
---
## Dev commands
Full setup in [DEV-ENV.md](DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
```bash
docker compose -f docker-compose.dev.yml up -d # start stack
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
cd frontend && npm run dev
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
cd backend && alembic upgrade head # migrate
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
cd frontend && npm run build # stricter than tsc --noEmit — final check
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
python -m scripts.seed_trees # seed (from backend/)
```
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
**CI:** Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
---
## Common tasks
- **New endpoint:** `endpoints/``router.py``schemas/` → tests → frontend API client.
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx``AccountLayout` has NO sidebar nav.
---
## Coding standards
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Format: `type: description` (feat/fix/refactor/docs/test/chore). Always `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`. Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly.
**After shipping:** update `CURRENT-STATE.md` + `03-DEVELOPMENT-ROADMAP.md`, `gh issue close #N` for resolved issues, add lessons here only for non-obvious traps (otherwise let the code speak).
---
## Frontend patterns
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage``/ai-sessions/{id}/chat``unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
- **Node field priority** (copilot, summaries): `title``question``description``content``label`.
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
---
## Critical lessons
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
### Backend / data
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
### AI / FlowPilot
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
### Frontend / UI
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
### Env / infra
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
- **Dev env** — see DEV-ENV.md for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
---
## GitNexus code intelligence
### GitNexus code intelligence
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
@@ -224,42 +52,23 @@ Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
---
### gstack skills
## gstack skills
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
Always use `/browse` for web, never `mcp__claude-in-chrome__*`. Most-used:
Available commands:
- `/review` — pre-land PR review
- `/ship` — tests + review + PR creation
- `/browse` + `/qa` / `/qa-only` — headless browser testing (setup: Lesson 82)
- `/design-review` — visual QA
- `/investigate` — systematic debug with root cause
- `/codex` OpenAI Codex second opinion
- `/plan-eng-review` / `/plan-design-review` / `/plan-ceo-review` — plan critiques
- **Planning & review:** `/autoplan`, `/plan-eng-review`, `/plan-design-review`, `/plan-ceo-review`, `/plan-devex-review`, `/devex-review`, `/review`, `/cso`, `/office-hours`
- **Design:** `/design-consultation`, `/design-shotgun`, `/design-html`, `/design-review`
- **Browser & QA:** `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/setup-browser-cookies`
- **Ship & deploy:** `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/setup-deploy`, `/document-release`
- **Debug & investigate:** `/investigate`, `/careful`, `/freeze`, `/guard`, `/unfreeze`
- **Other:** `/codex` (OpenAI second opinion), `/setup-gbrain`, `/retro`, `/learn`, `/gstack-upgrade`
---
### Git trailer
## Deployment (Railway)
Every commit: `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
### Model aliases
---
## Quick reference
| What | Where |
|---|---|
| Detailed status | [CURRENT-STATE.md](CURRENT-STATE.md) |
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
| Design system | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev env | [DEV-ENV.md](DEV-ENV.md) |
| Archived lessons | [docs/LESSONS-ARCHIVE.md](docs/LESSONS-ARCHIVE.md) |
| ConnectWise API | `docs/connectwise/` |
| GitHub issues | `gh issue list --state open` |
| Local API docs | <http://localhost:8000/api/docs> |
Always use alias form (`claude-sonnet-4-6`, `claude-opus-4-6`, etc.) via `settings.get_model_for_action()`. Never hardcode a dated model ID.

View File

@@ -1,70 +0,0 @@
# Session Handoff — Design System v4 Migration
> **For the next Claude session:** Read this file completely, internalize the context, then delete it (`rm SESSION-HANDOFF.md`). This is a one-time context transfer.
---
## What Was Done This Session
### 1. FlowPilot Message Bar + AI Script Builder (MERGED to main)
- PR #118 merged. Always-visible message bar in FlowPilot sessions, AI Script Builder at `/script-builder`, library reorg (My/Team Scripts tabs), FlowPilot-to-Script-Builder handoff, session abandon/close, unified session history.
- Eng review completed: normalized `script_builder_messages` table, typed content helpers, 6 edge case tests.
### 2. Design System v4 Migration (PR #119, open, branch: `refactor/design-system-v4`)
- Complete frontend redesign from glassmorphism to flat dark theme (Sentry/PostHog-inspired)
- **CSS Foundation:** New color tokens in `index.css`, all via CSS custom properties. Light mode ready (just needs `.light` class values).
- **Icon Rail Sidebar:** 72px rail with 5 grouped icons (Home, Work, Knowledge, Insights, Help). Full-height resizable drawer on hover. Pin-to-expand to 260px. Mobile hamburger overlay.
- **Component Sweep:** ~200 files migrated. All hardcoded hex replaced with semantic Tailwind tokens (bg-card, text-foreground, border-border, etc.).
- **Landing Page:** Flat surfaces, no glow, solid buttons.
- **Interactive Shadows:** Dark-mode-aware — elevated surfaces + faint cyan accent glow (black shadows invisible on dark bg).
- **Stat Cards:** 3px colored left borders.
- **Tab Toggles:** Active state uses `tab-active-shadow` (elevated bg + faint glow).
### 3. GTM Strategy (from /office-hours)
- Shadow & Ship approach: Michael uses ResolutionFlow on real tickets for 2 weeks, then hands logins to 5 MSP colleagues. Key metric: unprompted return.
- Design doc at `~/.gstack/projects/patherly-patherly/`
---
## What Needs To Be Done Next
### Immediate (Design System v4 polish)
1. **Home icon color fix:** The Home icon in the sidebar shouldn't have a cyan background when not active. Instead, the Home icon itself should always be cyan (brand accent), and only show the `bg-accent-dim` background when the route is actually `/`. Michael specifically requested this.
2. **Visual QA pass:** Michael hasn't done a full page-by-page walkthrough yet. Expect feedback on individual pages once he does.
3. **`font-label` cleanup:** ~10 files still reference `font-label` (deprecated alias for `font-mono`). Each needs inspection — some should be `font-mono`, others `font-sans text-xs`.
4. **Inline `style` attributes:** ~29 instances still use hardcoded hex in inline styles (sidebar, drawer, badges). Should be converted to CSS variable references or Tailwind classes where possible.
### Before Merging PR #119
- Run migrations: `docker exec resolutionflow_backend alembic upgrade head` (new tables from the Script Builder PR are on main now)
- Full visual QA with backend running
- Test mobile responsive (hamburger menu)
- Test FlowPilot session with new message bar + action bar positioning
### Future
- **Light mode toggle:** CSS variables are ready. Need to add `.light` class values in `index.css` + toggle in user settings/account page.
- **Script Builder testing:** The AI Script Builder hasn't been tested end-to-end with the backend running yet.
---
## Key Files to Know
| File | What it does |
|------|-------------|
| `DESIGN-SYSTEM.md` | Single source of truth for all design decisions |
| `frontend/src/index.css` | CSS tokens, component utilities, shadow patterns |
| `frontend/src/components/layout/Sidebar.tsx` | Icon rail + drawer + pinned sidebar |
| `frontend/src/components/layout/AppLayout.tsx` | CSS Grid shell |
| `frontend/src/components/dashboard/StartSessionInput.tsx` | The Guided/Chat toggle |
| `frontend/src/components/dashboard/PerformanceCards.tsx` | Stat cards with colored borders |
## Key Lessons From This Session
- The component sweep agents missed `editor-ai/`, `guides/`, `maintenance/`, `scripts/`, `settings/` directories and `text-brand-dark` references. Always do a final grep audit after sweeps.
- `bg-[#hex]` hardcoding defeats the purpose of CSS variables. We had to do a second pass to replace 3,200+ hardcoded values with semantic tokens.
- Black shadows (`rgba(0,0,0,...)`) are invisible on dark backgrounds. Use elevated surfaces + faint accent glow instead.
- The sidebar flyout needed `position: fixed` to escape the CSS Grid cell clipping — `absolute` positioning was hidden behind the main content area.
- Flyout hover timing: individual item `onMouseLeave` was killing the flyout before the mouse reached the drawer. Only the outer wrapper should handle `onMouseLeave`.
---
> **After reading this file:** Save relevant context to your session memory, then run `rm SESSION-HANDOFF.md` and `git add -A && git commit -m "chore: remove session handoff file"`.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0.0

View File

@@ -5,6 +5,12 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
libpango1.0-dev \
libcairo2-dev \
libgdk-pixbuf-2.0-dev \
libffi-dev \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./
@@ -12,4 +18,4 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
EXPOSE 8000
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]

View File

@@ -0,0 +1,60 @@
"""add applied_pending status + pending_reason to session_suggested_fixes
Adds the `applied_pending` non-terminal status (engineer ran the fix but
verification is deferred — waiting on client, async sync, etc) alongside
the existing `applied_partial` status. Mirrors partial_notes with a new
pending_reason column for the "what are you waiting on?" prose.
Revision ID: c0f3a4b7e91d
Revises: 71efd2102f49
Create Date: 2026-04-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c0f3a4b7e91d"
down_revision: Union[str, None] = "71efd2102f49"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"session_suggested_fixes",
sa.Column("pending_reason", sa.Text(), nullable=True),
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'applied_pending', 'dismissed')",
)
def downgrade() -> None:
op.execute(
"UPDATE session_suggested_fixes "
"SET status = 'applied_partial', "
" partial_notes = COALESCE(partial_notes, pending_reason) "
"WHERE status = 'applied_pending'"
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
)
op.drop_column("session_suggested_fixes", "pending_reason")

View File

@@ -15,7 +15,7 @@ from datetime import datetime
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
from sqlalchemy import or_, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -452,6 +452,13 @@ async def resolve_session(
# ── Escalate ──
#
# Thin shim over HandoffManager. The legacy `flowpilot_engine.escalate_session`
# is no longer the source of truth — every escalation now creates a
# SessionHandoff row, fans out via the SSE bus, dispatches AppNotification +
# external channels via notify(), and emails per-user. EscalateModal and the
# /handoff endpoint both funnel through here / through HandoffManager so the
# senior-pickup magic-moment screen works regardless of entry point.
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
@@ -459,25 +466,62 @@ async def escalate_session(
request: Request,
session_id: UUID,
data: EscalateSessionRequest,
background_tasks: BackgroundTasks,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Escalate a FlowPilot session to another engineer."""
"""Escalate a FlowPilot session — unified through HandoffManager."""
from app.services.handoff_manager import HandoffManager, enrich_escalation_async
# Owner-only — matches the original constraint on flowpilot_engine.escalate_session.
session_result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == current_user.id,
)
)
session = session_result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
manager = HandoffManager(db)
try:
result = await flowpilot_engine.escalate_session(
handoff = await manager.create_handoff(
session_id=session_id,
request=data,
intent="escalate",
engineer_notes=data.escalation_reason,
user_id=current_user.id,
db=db,
priority="normal",
target_user_id=data.escalated_to_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
documentation, psa_result = await manager.finalize_escalation(
handoff, session, current_user.id
)
await db.commit()
return result
await manager.dispatch_escalation_notifications(handoff)
# AI enrichment (Sonnet assessment + enhanced escalation_package) runs
# in the background so the escalating engineer doesn't wait on
# 15-25s of model latency. Result lands on the handoff row when ready;
# the senior's magic-moment screen reads it at pickup time.
background_tasks.add_task(
enrich_escalation_async, handoff.id, current_user.id
)
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
**psa_result,
)
# ── Pause ──
@@ -644,7 +688,8 @@ async def get_escalation_queue(
select(AISession)
.where(
scope_filter,
AISession.status == "requesting_escalation",
AISession.status.in_(("requesting_escalation", "escalated")),
AISession.user_id != current_user.id,
)
.order_by(AISession.created_at.desc())
)
@@ -838,13 +883,25 @@ async def list_sessions(
date_to: Optional[datetime] = Query(None),
q: Optional[str] = Query(None, min_length=2, max_length=200),
):
"""List the current user's AI sessions (owned or picked up)."""
"""List the current user's AI sessions (owned or picked up).
"Picked up" includes both the legacy escalation_package.picked_up_by
marker (set by flowpilot_engine.pickup_session) AND the new
escalated_to_id field (set by HandoffManager.claim_session for the
unified handoff/escalate path). Without the escalated_to_id branch
the senior tech wouldn't see a session they just claimed in their
chat sidebar — the picked-up session lands as the active chat with
no entry in the list, which is what the user reported as "4 versions
of the session" (their unrelated owned sessions show up while the
claimed one is invisible).
"""
user_id_str = str(current_user.id)
query = (
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.escalated_to_id == current_user.id,
AISession.escalation_package["picked_up_by"].as_string() == user_id_str,
)
)
@@ -901,10 +958,21 @@ async def get_session(
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
# Allow access if user is owner, escalation target, or picked-up handler
# Allow access if user is owner, escalation target, or picked-up handler.
# Sessions in transit (requesting_escalation / escalated) are also
# readable by any account member — the whole point of escalation is that
# other techs can see the context before claiming. Tenant boundary is
# enforced by RLS on the underlying query, so account-scope is the right
# ceiling for in-transit reads.
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
is_in_transit = session.status in ("requesting_escalation", "escalated")
if (
session.user_id != current_user.id
and session.escalated_to_id != current_user.id
and not is_handler
and not is_in_transit
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return _build_session_detail(session)

View File

@@ -3,8 +3,10 @@
Endpoints:
GET /analytics/flowpilot?period=30d — Main dashboard data
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
GET /analytics/flowpilot/escalations?period=30d — Escalation handoff metrics
"""
import logging
import statistics
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
@@ -13,10 +15,17 @@ from sqlalchemy import select, func, case, cast, Date, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_team_admin
from app.api.deps import (
get_current_active_user,
get_db,
require_engineer_or_admin,
require_team_admin,
)
from app.models.user import User
from app.models.tree import Tree
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.models.session_handoff import SessionHandoff
from app.models.flow_proposal import FlowProposal
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_post_log import PsaPostLog
@@ -36,6 +45,7 @@ from app.schemas.flowpilot_analytics import (
EnhancedPsaMetrics,
PsaFunnel,
PsaDailyTrend,
EscalationMetrics,
)
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
@@ -727,3 +737,104 @@ async def get_enhanced_psa_metrics(
push_funnel=push_funnel,
daily_trend=daily_trend,
)
# ─── Escalation Mode metrics (wedge stat for /escalations queue + analytics page)
#
# Pulls all (handoff.claimed_at, first_step_after_claim.created_at) pairs in the
# window and aggregates avg/median/p95 of the delta in Python. Pilot scale
# (~1k rows max per account per month) makes this cheaper and clearer than
# Postgres percentile_cont gymnastics.
#
# IMPORTANT: this is the in-product metric only. The "minutes recovered"
# sales claim requires manual baseline measurement (see The Assignment in
# docs/plans/2026-04-27-escalation-mode-wedge-design.md).
@router.get("/escalations", response_model=EscalationMetrics)
@limiter.limit("30/minute")
async def get_escalation_metrics(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
) -> EscalationMetrics:
"""Time-to-first-action after escalation claim, account-scoped.
Returns:
n_handoffs_claimed: handoffs in window that were claimed by a senior.
n_handoffs_with_action: subset where the senior took at least one
action (an ai_session_step row created after claimed_at).
avg/median/p95_seconds_to_first_action: aggregates of
(first_step.created_at - claimed_at) in seconds.
Excludes handoffs where claimed_at IS NULL (never claimed) and handoffs
where no ai_session_step was created after the claim. Both are
counted — n_handoffs_claimed includes "no action yet" handoffs so the
conversion rate is visible.
"""
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
)
account_id = current_user.account_id
period_start = _get_period_start(period)
# First-action timestamp per handoff via correlated scalar subquery.
first_action_subq = (
select(func.min(AISessionStep.created_at))
.where(
AISessionStep.session_id == SessionHandoff.session_id,
AISessionStep.created_at > SessionHandoff.claimed_at,
)
.correlate(SessionHandoff)
.scalar_subquery()
)
rows = (
await db.execute(
select(
SessionHandoff.claimed_at,
first_action_subq.label("first_action_at"),
).where(
SessionHandoff.account_id == account_id,
SessionHandoff.claimed_at.isnot(None),
SessionHandoff.claimed_at >= period_start,
)
)
).all()
n_handoffs_claimed = len(rows)
deltas: list[float] = []
for claimed_at, first_action_at in rows:
if first_action_at is None:
continue
delta_s = (first_action_at - claimed_at).total_seconds()
# Floor at zero — clock drift between rows could in theory yield a
# tiny negative if a step's created_at races claimed_at. Surface as
# 0s rather than absurd negative deltas.
if delta_s < 0:
delta_s = 0.0
deltas.append(delta_s)
n_handoffs_with_action = len(deltas)
if n_handoffs_with_action == 0:
return EscalationMetrics(
period=period,
n_handoffs_claimed=n_handoffs_claimed,
n_handoffs_with_action=0,
)
sorted_deltas = sorted(deltas)
p95_idx = max(0, int(round(0.95 * (n_handoffs_with_action - 1))))
return EscalationMetrics(
period=period,
n_handoffs_claimed=n_handoffs_claimed,
n_handoffs_with_action=n_handoffs_with_action,
avg_seconds_to_first_action=round(statistics.fmean(deltas), 2),
median_seconds_to_first_action=round(statistics.median(deltas), 2),
p95_seconds_to_first_action=round(sorted_deltas[p95_idx], 2),
)

View File

@@ -194,6 +194,7 @@ async def create_folder(
new_folder = UserFolder(
user_id=current_user.id,
account_id=current_user.account_id,
name=folder_data.name,
color=folder_data.color,
icon=folder_data.icon,

View File

@@ -1,6 +1,7 @@
"""PSA integration endpoints — connection CRUD and test."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
logger = logging.getLogger(__name__)
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
from app.core.database import get_db
from app.models.psa_connection import PsaConnection
@@ -30,6 +33,17 @@ from app.schemas.psa_connection import (
PSABoardResponse,
)
from app.core.config import settings
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
TicketCreatePayloadSchema,
PSAPrioritySchema,
TicketListResponseSchema,
AiParseRequestSchema,
AiParseResponseSchema,
)
import app.services.ticket_service as ticket_svc
from app.services.psa.encryption import (
decrypt_credentials,
encrypt_credentials,
@@ -362,33 +376,36 @@ async def list_boards(
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
except PSAError:
except PSAError as e:
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
logger.warning("list_boards failed: %s", e)
return []
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
@router.get("/tickets/search", response_model=TicketListResponseSchema)
async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
status_name: str | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
priority: str | None = None,
company_id: int | None = None,
page: int = 1,
page_size: int = 10,
page_size: int = 25,
):
"""Search ConnectWise tickets."""
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
member_identifier: str | None = None
if assigned_to_me:
conn_result = await db.execute(
@@ -407,23 +424,18 @@ async def search_tickets(
)
mapping = mapping_result.scalar_one_or_none()
if not mapping:
# No mapping for this user — return empty list
return []
from app.services.psa.registry import get_provider_for_account as _get_provider
from app.services.psa.exceptions import PSAError as _PSAError
return {"items": [], "total": 0, "page": page, "page_size": page_size}
try:
_provider = await _get_provider(current_user.account_id, db)
_provider = await get_provider_for_account(current_user.account_id, db)
cw_members = await _provider.list_members()
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
if matched:
member_identifier = matched.identifier
else:
return []
except _PSAError:
return []
return {"items": [], "total": 0, "page": page, "page_size": page_size}
except PSAError:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
# Parse comma-separated board_ids
parsed_board_ids: list[int] = []
if board_ids:
try:
@@ -433,33 +445,250 @@ async def search_tickets(
try:
provider = await get_provider_for_account(current_user.account_id, db)
tickets = await provider.search_tickets(
result = await provider.search_tickets(
query,
board_id=board_id,
status_id=status_id,
status_name=status_name,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,
board_ids=parsed_board_ids,
company_id=company_id,
page=page,
page_size=page_size,
)
return [
items = [
PSATicketSearchResult(
id=t.id,
summary=t.summary,
company_name=t.company_name,
company_id=t.company_id,
board_name=t.board_name,
board_id=t.board_id,
status_name=t.status_name,
status_id=t.status_id,
priority_name=t.priority_name,
priority_id=t.priority_id,
closed=t.closed,
)
for t in tickets
for t in result.items
]
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
async def create_ticket(
data: TicketCreatePayloadSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA ticket."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
from app.services.psa.types import TicketCreatePayload
try:
return await ticket_svc.create_ticket(
current_user.account_id,
TicketCreatePayload(**data.model_dump()),
db,
)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
async def ai_parse_ticket(
data: AiParseRequestSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Parse natural language into a ticket pre-fill payload using Claude."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
import anthropic
import json
# Fetch boards + members for context (both cached)
boards = []
members = []
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
members = await provider.list_members()
except PSAError:
pass
boards_list = [{"id": b.id, "name": b.name} for b in boards]
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
system_prompt = """You are a ticket triage assistant for an MSP help desk.
Extract structured ticket information from the engineer's natural language description.
Return ONLY valid JSON matching this exact schema — no other text:
{
"summary": "short one-line ticket title or null",
"board_id": "integer matching one of the provided boards or null",
"priority_name": "one of: Critical, High, Medium, Low, or null",
"description": "expanded description or null",
"assignee_identifier": "member identifier string from the provided members list or null",
"warnings": ["list of strings explaining what could not be resolved"]
}"""
user_msg = f"""Available boards: {json.dumps(boards_list)}
Available members: {json.dumps(members_list[:50])}
Engineer's description: {data.prompt}"""
missing_fields: list[str] = []
warnings: list[str] = []
response_data = AiParseResponseSchema()
try:
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
max_retries=1,
)
msg = await client.messages.create(
model=settings.get_model_for_action("default"),
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
)
raw = msg.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
import re
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw.strip())
parsed = json.loads(raw)
response_data.summary = parsed.get("summary")
response_data.description = parsed.get("description")
warnings = parsed.get("warnings", [])
# Resolve board_id
if parsed.get("board_id"):
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
if board_match:
response_data.board_id = board_match.id
else:
missing_fields.append("board_id")
warnings.append(f"Board ID {parsed['board_id']} not found")
else:
missing_fields.append("board_id")
# Resolve assignee
if parsed.get("assignee_identifier"):
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
if member:
response_data.assigned_member_id = int(member.id)
else:
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
# Priority/status/company always need manual selection
missing_fields.extend(["status_id", "priority_id", "company_id"])
except Exception as e:
logger.warning("AI parse failed: %s", e)
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
warnings = ["AI parsing failed — please fill in manually"]
response_data.missing_fields = missing_fields
response_data.warnings = warnings
return response_data
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
async def update_ticket_status_endpoint(
ticket_id: int,
status_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a ticket's status."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
async def list_ticket_resources(
ticket_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
except PSAError as e:
# Resources are optional display data — degrade gracefully rather than surfacing a toast
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
return []
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
async def add_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
async def remove_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/priorities", response_model=list[PSAPrioritySchema])
async def list_priorities(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA priority levels for ticket creation form."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
raw = await provider.list_priorities()
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
except PSAError as e:
logger.warning("list_priorities failed: %s", e)
return []
@router.get("/tickets/{ticket_id}/context")
async def get_ticket_context(
ticket_id: int,
@@ -561,7 +790,30 @@ async def get_ticket_statuses(
except PSANotFoundError:
raise HTTPException(status_code=404, detail="Ticket not found")
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
return []
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
async def get_board_statuses(
board_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get available statuses for a service board directly (no ticket lookup required)."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
statuses = await provider.get_ticket_statuses(board_id)
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
except PSAError as e:
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
return []
# ── member mapping endpoints ─────────────────────────────────────────
@@ -569,7 +821,7 @@ async def get_ticket_statuses(
@router.get("/members", response_model=list[PsaMemberResponse])
async def list_members(
current_user: Annotated[User, Depends(require_account_owner)],
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List CW members (from CW API)."""
@@ -587,7 +839,9 @@ async def list_members(
for m in members
]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
# Members are optional display data — degrade gracefully
logger.warning("list_members failed: %s", e)
return []
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])

View File

@@ -260,6 +260,7 @@ async def save_to_library(
category_id=data.category_id,
share_with_team=data.share_with_team,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
script_body=data.script_body,
parameters_schema=data.parameters_schema,

View File

@@ -1,23 +1,28 @@
"""Handoff endpoints — unified park/escalate.
POST /ai-sessions/{id}/handoff — Create handoff
POST /ai-sessions/{id}/handoff — Create handoff
GET /ai-sessions/{id}/handoffs — Handoff history
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
GET /ai-sessions/queue — Team queue
GET /ai-sessions/queue — Team queue
GET /ai-sessions/escalations/stream — SSE: live escalation arrivals
"""
import asyncio
import json
import logging
from typing import Annotated
from typing import Annotated, AsyncGenerator
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user, get_db
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.escalation_bus import bus as escalation_bus
from app.models.user import User
from app.models.ai_session import AISession
from app.models.session_handoff import SessionHandoff
from app.services.handoff_manager import HandoffManager
from app.services.handoff_manager import HandoffAlreadyClaimedError, HandoffManager
from app.schemas.session_handoff import (
HandoffCreateRequest,
HandoffResponse,
@@ -36,6 +41,7 @@ router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"]
async def create_handoff(
session_id: UUID,
body: HandoffCreateRequest,
background_tasks: BackgroundTasks,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> HandoffResponse:
@@ -58,12 +64,35 @@ async def create_handoff(
engineer_notes=body.engineer_notes,
user_id=current_user.id,
priority=body.priority,
target_user_id=body.target_user_id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# For escalate: generate documentation + push to PSA before commit so
# the handoff and the PSA-state changes land atomically.
if handoff.intent == "escalate":
await manager.finalize_escalation(handoff, session, current_user.id)
await db.commit()
return HandoffResponse.model_validate(handoff)
# Best-effort notification dispatch AFTER commit so we never email about
# a rolled-back handoff. Failures are swallowed inside the manager —
# handoff creation is authoritative; notifications are advisory.
if handoff.intent == "escalate":
from app.services.handoff_manager import enrich_escalation_async
await manager.dispatch_escalation_notifications(handoff)
# AI enrichment (Sonnet assessment + enhanced escalation_package)
# runs in the background after the response is sent so the
# escalating engineer doesn't wait on 15-25s of model latency.
background_tasks.add_task(
enrich_escalation_async, handoff.id, current_user.id
)
return HandoffResponse.model_validate(handoff).model_copy(
update={"handed_off_by_name": current_user.name}
)
@router.get("/handoffs", response_model=list[HandoffResponse])
@@ -86,21 +115,49 @@ async def list_handoffs(
async def claim_handoff(
session_id: UUID,
handoff_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> HandoffResponse:
"""Claim a handed-off session."""
"""Claim a handed-off session.
Role-gated to engineer/admin/owner — viewers cannot claim. The race-condition
story (two seniors clicking Pick Up simultaneously) depends on auth gating
for audit integrity. Codex review flagged this as wedge-relevant; locked
in-scope for Escalation Mode v1.
"""
manager = HandoffManager(db)
try:
handoff = await manager.claim_session(
handoff_id=handoff_id,
claiming_user_id=current_user.id,
)
except HandoffAlreadyClaimedError as e:
# Loser of the race — the API surfaces structured detail so the
# client can render "Already claimed by {name} {time_ago}" without
# a follow-up fetch.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": "already_claimed",
"claimed_by_id": str(e.claimed_by_id),
"claimed_by_name": e.claimed_by_name,
"claimed_at": e.claimed_at.isoformat(),
},
)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
await db.commit()
return HandoffResponse.model_validate(handoff)
handed_off_by_name = (
handoff.handed_off_by_user.name
if handoff.handed_off_by_user
else None
)
return HandoffResponse.model_validate(handoff).model_copy(
update={"handed_off_by_name": handed_off_by_name}
)
@queue_router.get("/queue")
@@ -114,3 +171,83 @@ async def get_queue(
team_id=current_user.team_id,
account_id=current_user.account_id,
)
# ─── Live escalation arrivals (SSE) ──────────────────────────────────────────
#
# Streams `handoff_created` events to subscribers in the same account_id as
# the new handoff. Connected EscalationQueue instances prepend the new card
# with the locked 200ms slide-in. Account-scoped: cross-tenant leakage is
# prevented at the bus.publish boundary (only handoff.account_id subscribers
# are notified) and re-enforced here by binding the subscription to
# current_user.account_id.
#
# Heartbeat: a `: keepalive\n\n` SSE comment every 25s keeps the connection
# alive through Railway / nginx default 60s idle timeouts. Reconnect policy
# is on the client (browser EventSource auto-reconnects; our fetch-based
# reader retries with backoff).
_HEARTBEAT_INTERVAL_S = 25
_QUEUE_GET_TIMEOUT_S = 25 # < heartbeat so heartbeat fires reliably
@queue_router.get("/escalations/stream")
async def stream_escalations(
request: Request,
current_user: Annotated[
User,
Depends(require_engineer_or_admin, scope="function"),
],
):
"""SSE stream of new escalation arrivals for the current user's account.
Role-gated to engineer/admin/owner so viewers can't subscribe (matches
the queue + claim role surface). One open connection per browser tab is
expected; the bus handles fan-out.
"""
if not current_user.account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
)
account_id = current_user.account_id
async def event_generator() -> AsyncGenerator[str, None]:
queue = await escalation_bus.subscribe(account_id)
try:
# Initial hello so the client knows the stream is live.
yield (
"event: ready\n"
f"data: {json.dumps({'account_id': str(account_id)})}\n\n"
)
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(
queue.get(), timeout=_QUEUE_GET_TIMEOUT_S
)
except asyncio.TimeoutError:
# Heartbeat keeps the connection alive through proxies.
yield ": keepalive\n\n"
continue
event_type = event.get("type", "message")
yield (
f"event: {event_type}\n"
f"data: {json.dumps(event)}\n\n"
)
finally:
await escalation_bus.unsubscribe(account_id, queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -318,6 +318,11 @@ async def patch_suggested_fix_outcome(
status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_partial",
)
if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_pending",
)
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
if fix.status in TERMINAL:
@@ -329,6 +334,10 @@ async def patch_suggested_fix_outcome(
fix.status = body.outcome
if body.outcome == "applied_partial":
fix.partial_notes = (body.notes or "").strip() or None
elif body.outcome == "applied_pending":
# Pending is parked, not terminal — keep applied_at, do NOT stamp
# verified_at. Reason explains what the engineer is waiting on.
fix.pending_reason = (body.notes or "").strip() or None
elif body.outcome == "applied_failed":
fix.failure_reason = (body.notes or "").strip() or None
fix.verified_at = now

View File

@@ -20,6 +20,7 @@ from app.core.audit import log_audit
from app.core.rate_limit import limiter
router = APIRouter(tags=["shares"])
public_router = APIRouter(tags=["shares"])
def build_share_response(share: SessionShare) -> ShareResponse:
@@ -206,7 +207,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
return None
@router.get("/share/{share_token}", response_model=SharePublicView)
@public_router.get("/share/{share_token}", response_model=SharePublicView)
@limiter.limit("30/minute")
async def access_share(
share_token: str,

View File

@@ -161,7 +161,7 @@ async def get_sidebar_stats(
select(func.count()).where(
and_(
esc_scope,
AISession.status == "requesting_escalation",
AISession.status.in_(("requesting_escalation", "escalated")),
)
)
)

View File

@@ -78,9 +78,11 @@ api_router = APIRouter()
# ---------------------------------------------------------------------------
api_router.include_router(auth.router)
api_router.include_router(shared.router) # Public share links (no auth)
api_router.include_router(shares.public_router) # Public session share links (optional auth)
api_router.include_router(beta_signup.router)
api_router.include_router(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only
@@ -125,7 +127,6 @@ api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
api_router.include_router(copilot.router, dependencies=_tenant_deps)
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
api_router.include_router(survey.router, dependencies=_tenant_deps)
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)

View File

@@ -111,6 +111,16 @@ class Settings(BaseSettings):
GOOGLE_AI_API_KEY: Optional[str] = None
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
# senior's click — only how long we wait before publishing
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
# leaving the magic-moment placeholder permanent. 45s is the right
# ceiling: well above Sonnet p99 for a 500-token output, far enough
# below "the senior gives up watching" that we still surface SOMETHING
# on persistent slowness.
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 45
# Model tier routing — maps action types to model tiers
AI_MODEL_TIERS: dict[str, str] = {

View File

@@ -0,0 +1,105 @@
"""In-memory pub/sub bus for live escalation events.
Single-process, non-durable. When a handoff fires, every connected SSE
subscriber for the same `account_id` receives the event. Subscribers come
and go as senior techs open and close the EscalationQueue page.
Pre-PMF scale (3 pilots × 5-20 techs/MSP = ~15-60 concurrent subscribers
total, single Railway replica) makes in-memory the right call. When the
deployment scales horizontally, swap this for Redis pub/sub or similar —
the public surface (`publish` / `subscribe`) is intentionally narrow so
the swap is local.
Events are JSON-serializable dicts. `publish()` is non-blocking (drops the
event if a subscriber's queue is full rather than back-pressuring the
caller). `subscribe()` MUST be paired with `unsubscribe()` in a finally
block, or you leak queues.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from uuid import UUID
logger = logging.getLogger(__name__)
# Bound how many unconsumed events can sit in a subscriber's queue before
# we start dropping. 64 is generous for the queue-page use case; if a
# subscriber is that far behind, they're probably gone or stuck.
_QUEUE_MAXSIZE = 64
class EscalationBus:
"""Account-scoped pub/sub for escalation arrival events."""
def __init__(self) -> None:
self._subscribers: dict[UUID, set[asyncio.Queue[dict[str, Any]]]] = {}
self._lock = asyncio.Lock()
@staticmethod
def _normalize_account_id(account_id: UUID | str) -> UUID:
return account_id if isinstance(account_id, UUID) else UUID(str(account_id))
async def subscribe(self, account_id: UUID | str) -> asyncio.Queue[dict[str, Any]]:
"""Register a new subscriber queue for an account.
Caller must invoke `unsubscribe(account_id, queue)` when the
consumer disconnects.
"""
normalized_account_id = self._normalize_account_id(account_id)
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(
maxsize=_QUEUE_MAXSIZE
)
async with self._lock:
self._subscribers.setdefault(normalized_account_id, set()).add(queue)
return queue
async def unsubscribe(
self, account_id: UUID | str, queue: asyncio.Queue[dict[str, Any]]
) -> None:
normalized_account_id = self._normalize_account_id(account_id)
async with self._lock:
subs = self._subscribers.get(normalized_account_id)
if subs is None:
return
subs.discard(queue)
if not subs:
self._subscribers.pop(normalized_account_id, None)
async def publish(self, account_id: UUID | str, event: dict[str, Any]) -> int:
"""Fan event out to every subscriber for `account_id`.
Returns the number of subscribers that successfully received the
event. Drops the event for any subscriber whose queue is full
(logs at warning level).
"""
normalized_account_id = self._normalize_account_id(account_id)
async with self._lock:
subs = list(self._subscribers.get(normalized_account_id, ()))
if not subs:
return 0
delivered = 0
for queue in subs:
try:
queue.put_nowait(event)
delivered += 1
except asyncio.QueueFull:
logger.warning(
"EscalationBus: dropped event for full subscriber queue "
"(account_id=%s, event=%s)",
normalized_account_id,
event.get("type", "?"),
)
return delivered
def subscriber_count(self, account_id: UUID | str) -> int:
"""Diagnostic — number of active subscribers for an account."""
normalized_account_id = self._normalize_account_id(account_id)
return len(self._subscribers.get(normalized_account_id, ()))
# Module-level singleton. FastAPI imports this; `subscribe()` and `publish()`
# are coroutine-safe via the internal Lock.
bus = EscalationBus()

View File

@@ -10,7 +10,7 @@ from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.dialects.postgresql import UUID, JSONB, TSVECTOR
from app.core.database import Base
@@ -46,6 +46,7 @@ class AISession(Base):
"confidence_tier IN ('guided', 'exploring', 'discovery')",
name="ck_ai_sessions_confidence_tier",
),
sa.Index("idx_ai_sessions_search", "search_vector", postgresql_using="gin"),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -150,6 +151,18 @@ class AISession(Base):
Text, nullable=True,
comment="Why escalated (set on escalation)",
)
search_vector: Mapped[Optional[str]] = mapped_column(
TSVECTOR,
sa.Computed(
"to_tsvector('english', "
"coalesce(problem_summary, '') || ' ' || "
"coalesce(resolution_summary, '') || ' ' || "
"coalesce(escalation_reason, '') || ' ' || "
"coalesce(problem_domain, ''))",
persisted=True,
),
nullable=True,
)
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",

View File

@@ -37,7 +37,7 @@ class SessionSuggestedFix(Base):
),
CheckConstraint(
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
"'applied_partial', 'applied_pending', 'dismissed')",
name="ck_session_suggested_fixes_status",
),
)
@@ -81,6 +81,7 @@ class SessionSuggestedFix(Base):
DateTime(timezone=True), nullable=True
)
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
pending_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True

View File

@@ -124,3 +124,26 @@ class FlowPilotDashboard(BaseModel):
confidence_breakdown: ConfidenceBreakdown
knowledge_coverage: KnowledgeCoverage
psa_metrics: PsaMetrics | None = None
class EscalationMetrics(BaseModel):
"""In-product time-to-first-action metric for the Escalation Mode wedge.
NOTE: this is the *in-product* metric (post-claim time-to-first-action). The
"minutes recovered" sales claim requires a manual baseline measurement of the
pre-Escalation-Mode verbal-handoff time. See
docs/plans/2026-04-27-escalation-mode-wedge-design.md for the two-metric
framing — do not roll this number alone into "minutes recovered."
"""
period: str
n_handoffs_claimed: int
n_handoffs_with_action: int
avg_seconds_to_first_action: float | None = None
median_seconds_to_first_action: float | None = None
p95_seconds_to_first_action: float | None = None
metric_definition: str = (
"elapsed_seconds(first ai_session_step in session where "
"created_at > SessionHandoff.claimed_at) — measures post-claim activity "
"lag, NOT verbal-handoff savings. Pair with manual baseline."
)

View File

@@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel):
id: str
summary: str
company_name: str | None = None
company_id: str | None = None
board_name: str | None = None
board_id: int | None = None
status_name: str | None = None
status_id: int | None = None
priority_name: str | None = None
priority_id: int | None = None
closed: bool = False

View File

@@ -0,0 +1,65 @@
"""Normalized DTOs for ticket management endpoints."""
from __future__ import annotations
from pydantic import BaseModel
class PSAResourceSchema(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSATicketCreatedSchema(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResourceSchema] = []
class PSATicketStatusUpdateSchema(BaseModel):
ticket_id: int
previous_status: str
new_status: str
new_status_id: int
class TicketCreatePayloadSchema(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponseSchema(BaseModel):
items: list = []
total: int = 0
page: int = 1
page_size: int = 25
class AiParseRequestSchema(BaseModel):
prompt: str
class AiParseResponseSchema(BaseModel):
summary: str | None = None
company_id: int | None = None
board_id: int | None = None
priority_id: int | None = None
status_id: int | None = None
assigned_member_id: int | None = None
description: str | None = None
missing_fields: list[str] = []
warnings: list[str] = []
class PSAPrioritySchema(BaseModel):
id: int
name: str

View File

@@ -10,12 +10,18 @@ class HandoffCreateRequest(BaseModel):
intent: str = Field(..., pattern="^(park|escalate)$")
engineer_notes: str | None = None
priority: str = Field("normal", pattern="^(normal|elevated)$")
# Optional escalation target — if set, only this user is the named
# recipient. Notification dispatch fans out to all engineer/admin/owner
# users in the account either way; this just records the original
# engineer's preferred recipient on the session for audit/UX.
target_user_id: UUID | None = None
class HandoffResponse(BaseModel):
id: UUID
session_id: UUID
handed_off_by: UUID
handed_off_by_name: str | None = None
intent: str
source_branch_id: UUID | None
snapshot: dict[str, Any]

View File

@@ -20,6 +20,7 @@ FixStatus = Literal[
"applied_success",
"applied_failed",
"applied_partial",
"applied_pending",
"dismissed",
]
@@ -40,6 +41,7 @@ class SessionSuggestedFixResponse(BaseModel):
applied_at: datetime | None
verified_at: datetime | None
partial_notes: str | None
pending_reason: str | None
failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None
@@ -91,7 +93,11 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
# Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[
"applied_success", "applied_failed", "applied_partial", "dismissed"
"applied_success",
"applied_failed",
"applied_partial",
"applied_pending",
"dismissed",
]
@@ -103,14 +109,18 @@ class SessionSuggestedFixOutcomeRequest(BaseModel):
engineer took); outcome captures whether the fix actually worked.
Allowed transitions:
- from `proposed` or `applied_partial`: any outcome is valid
(partial is parked, not terminal — the engineer may update notes,
abandon via dismiss, or advance to success/failed)
- from `proposed`, `applied_partial`, or `applied_pending`: any outcome
is valid. Partial means "did some of it"; pending means "did all of
it but verification is deferred (waiting on client, async sync, etc)".
Both are parked, not terminal — the engineer may advance them to
success/failed/dismiss.
- from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409
"""
outcome: FixOutcome
# Required for applied_partial, optional for applied_failed, ignored otherwise.
# Required for applied_partial AND applied_pending; optional for
# applied_failed; ignored otherwise. For pending, this is the
# "what are you waiting on?" reason (e.g. "client power-cycling router").
notes: str | None = Field(None, max_length=500)

View File

@@ -68,4 +68,6 @@ class RoleUpdate(BaseModel):
class AccountRoleUpdate(BaseModel):
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")
# Ownership changes must go through the explicit transfer-ownership flow so
# account.owner_id stays consistent with user.account_role.
account_role: str = Field(..., pattern="^(admin|engineer|viewer)$")

View File

@@ -295,6 +295,24 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
- If a question is clearly outside your domain, say so briefly and redirect.
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
## SPIN-OFF TICKET CREATION
When you identify a second distinct issue that is clearly separate from the primary topic \
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
Use `create_spin_off_ticket` as the command value for this action.
Format:
[ACTIONS]
[
{
"label": "Create ticket: <brief issue title>",
"command": "<spin-off ticket action command>",
"description": "<one sentence description of the separate issue>"
}
]
[/ACTIONS]
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \

View File

@@ -63,6 +63,9 @@ the active suggested fix, as given in the input bundle under "Outcome status":>
provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified.
- applied_pending: List the fix as applied but awaiting verification. Include \
the pending reason if provided (e.g. "client power-cycling router"). Make it \
clear the next engineer should follow up to confirm it worked.
- applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered.
@@ -80,6 +83,8 @@ symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open.
- applied_pending: Note that the fix is in place but unverified. Reference the \
pending reason. Frame this as the leading hypothesis pending confirmation.
- applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation.
@@ -92,6 +97,8 @@ accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \
If the fix is pending verification (applied_pending), the first step is \
typically to confirm whether the fix held — reference what was being waited on. \
If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).>
@@ -299,6 +306,8 @@ class EscalationPackageGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.pending_reason:
lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")

View File

@@ -632,8 +632,10 @@ async def pickup_session(
allow_team_access=True, team_id=team_id,
)
if session.status != "requesting_escalation":
raise ValueError(f"Session is {session.status}, not requesting_escalation")
if session.status not in ("requesting_escalation", "escalated"):
raise ValueError(
f"Session is {session.status}, not in an escalated state"
)
# Can't pick up your own session
if session.user_id == user_id:
@@ -911,6 +913,41 @@ async def generate_status_update(
"""Generate a status update for ticket notes, client communication, or email draft."""
session = await _load_session(session_id, user_id, db)
# For escalation/ticket_notes, return the pre-generated handoff prose immediately
# if enrich_escalation_async has already populated it. This eliminates the
# redundant Sonnet re-summarization on every "Ticket Notes" click.
if request.context == "escalation" and request.audience == "ticket_notes":
from app.models.session_handoff import SessionHandoff
handoff_q = await db.execute(
select(SessionHandoff)
.where(
SessionHandoff.session_id == session_id,
SessionHandoff.intent == "escalate",
)
.order_by(SessionHandoff.created_at.desc())
.limit(1)
)
escalation_handoff = handoff_q.scalar_one_or_none()
saved_data = (
escalation_handoff.ai_assessment_data or {}
) if escalation_handoff else {}
prose = saved_data.get("summary_prose") or (
escalation_handoff.ai_assessment if escalation_handoff else None
)
if prose:
return StatusUpdateResponse(
content=prose,
audience=request.audience,
length=request.length,
context=request.context,
session_status=session.status,
steps_completed=session.step_count or 0,
time_spent_display=None,
client_name=None,
generated_at=datetime.now(timezone.utc),
)
# Build conversation summary from session steps
steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order):

View File

@@ -3,22 +3,65 @@
Creates handoff snapshots, AI assessments (for escalations), claim workflow,
and queue queries. Dual-writes to ai_sessions.escalation_package for
backward compatibility with the existing escalation queue.
For intent='escalate', `create_handoff` also runs the legacy enrichment
that the deprecated `/escalate` endpoint used to do directly: setting
`escalated_to_id`, building the AI-enhanced escalation_package (Sonnet),
and recording escalation_reason. `finalize_escalation` then generates the
SessionDocumentation and pushes to PSA. `dispatch_escalation_notifications`
fans out the bell-icon AppNotification + external channels (Slack/Teams)
on top of per-user emails. The `/escalate` endpoint is now a thin shim
calling these in sequence.
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from uuid import UUID, uuid4
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.core.email import EmailService
from app.core.escalation_bus import bus as escalation_bus
from app.models.ai_session import AISession
from app.models.session_branch import SessionBranch
from app.models.session_handoff import SessionHandoff
from app.models.user import User
from app.schemas.ai_session import SessionDocumentation
from app.services.notification_service import notify
logger = logging.getLogger(__name__)
class HandoffAlreadyClaimedError(Exception):
"""Raised when a senior tries to claim a handoff another senior already won.
Carries the winning claimer's id, display name, and claim timestamp so the
API layer can surface a "Already claimed by {name} {time_ago}" toast on
the losing client. The race story is the locked design — without this
exception the endpoint would silently overwrite `claimed_by` and both
seniors would think they own the session.
"""
def __init__(
self,
claimed_by_id: UUID,
claimed_by_name: str,
claimed_at: datetime,
) -> None:
super().__init__(
f"Handoff already claimed by {claimed_by_name} at {claimed_at.isoformat()}"
)
self.claimed_by_id = claimed_by_id
self.claimed_by_name = claimed_by_name
self.claimed_at = claimed_at
class HandoffManager:
"""Unified park/escalate handoff management."""
@@ -32,37 +75,71 @@ class HandoffManager:
engineer_notes: str | None,
user_id: UUID,
priority: str = "normal",
target_user_id: UUID | None = None,
) -> SessionHandoff:
"""Create a handoff (park or escalate).
Generates snapshot, updates session status, dual-writes to
escalation_package for backward compat.
For intent='escalate' also: sets `session.escalation_reason` and
optionally `session.escalated_to_id`, builds the AI-enhanced
escalation package (the rich one the legacy `/escalate` path used
to produce), and merges the handoff metadata into it. Self-targeting
is rejected with ValueError, matching legacy behavior.
"""
user_id = UUID(str(user_id))
if target_user_id:
target_user_id = UUID(str(target_user_id))
# Eager-load steps + user — _build_escalation_package_enhanced and
# finalize_escalation iterate over session.steps to compose the
# legacy enriched package and the SessionDocumentation, and the
# notify() dispatcher reads session.user.name. Without selectinload
# the async session raises MissingGreenlet on attribute access.
result = await self.db.execute(
select(AISession).where(AISession.id == session_id)
select(AISession)
.options(
selectinload(AISession.steps),
selectinload(AISession.user),
)
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise ValueError(f"Session {session_id} not found")
# Generate snapshot
if intent == "escalate":
if target_user_id and target_user_id == user_id:
raise ValueError(
"Cannot escalate a session to yourself. Use pause instead."
)
if session.status not in ("active", "paused"):
raise ValueError(
f"Cannot escalate session in status: {session.status}"
)
# Generate snapshot — fast, no AI calls.
snapshot = await self._generate_snapshot(session)
# Generate AI assessment for escalations
ai_assessment = None
ai_assessment_data = None
if intent == "escalate":
ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session)
# AI enrichment (assessment + enhanced escalation_package) is now
# deferred to a background task scheduled by the endpoint after
# commit — both calls hit Sonnet and together can take 15-25s,
# which is too long to block the click path. The handoff row lands
# immediately with `ai_assessment=None`; the magic-moment screen
# shows "Assessment still computing" until enrich_async finishes
# and the senior refreshes (or, eventually, polls).
handoff_id = uuid4()
handoff = SessionHandoff(
id=handoff_id,
session_id=session_id,
account_id=session.account_id,
handed_off_by=user_id,
intent=intent,
source_branch_id=session.active_branch_id,
snapshot=snapshot,
ai_assessment=ai_assessment,
ai_assessment_data=ai_assessment_data,
ai_assessment=None,
ai_assessment_data=None,
engineer_notes=engineer_notes,
priority=priority,
)
@@ -73,20 +150,248 @@ class HandoffManager:
session.status = "paused"
elif intent == "escalate":
session.status = "escalated"
session.escalation_reason = engineer_notes
if target_user_id:
session.escalated_to_id = target_user_id
session.handoff_count = (session.handoff_count or 0) + 1
# Dual-write for backward compat
# Dual-write the minimal escalation_package shape now. The async
# enrichment task overwrites this with the AI-enhanced shape
# (`steps_tried`, `remaining_hypotheses`, etc.) when it completes —
# consumers that read these fields (PSA writeback, legacy
# SessionBriefing) tolerate either shape.
session.escalation_package = {
"snapshot": snapshot,
"intent": intent,
"engineer_notes": engineer_notes,
"handoff_id": str(handoff.id),
"handoff_id": str(handoff_id),
}
await self.db.flush()
return handoff
async def finalize_escalation(
self,
handoff: SessionHandoff,
session: AISession,
user_id: UUID,
) -> tuple[SessionDocumentation | None, dict[str, Any]]:
"""Post-create enrichment for intent='escalate' handoffs.
Generates the SessionDocumentation + pushes documentation to PSA if
a ticket is linked. Returns (documentation, psa_result) so the
legacy `/escalate` shim can map back to SessionCloseResponse. Safe
to call only when handoff.intent == 'escalate' — for park, returns
a no-op no-PSA dict.
"""
if handoff.intent != "escalate":
return None, {
"psa_push_status": "no_psa",
"psa_push_error": None,
"member_mapping_warning": None,
}
# Lazy import to avoid circular dependency: flowpilot_engine imports
# plenty of services at module load time and we don't want
# handoff_manager pulled into that graph at import.
from app.services.flowpilot_engine import (
_generate_documentation,
_push_to_psa,
)
documentation = _generate_documentation(session)
psa_result = await _push_to_psa(session, user_id, self.db)
# Bell-icon AppNotification rows + external account-level channels
# (Slack/Teams webhooks, shared escalations inboxes). This is the
# `notify()` call the legacy /escalate path used to make directly,
# and it has to happen BEFORE the endpoint commits so the
# AppNotification rows land atomically with the handoff. Per-user
# emails come after commit in dispatch_escalation_notifications —
# those are pure IO with no persistent state.
try:
engineer_user = (
await self.db.execute(
select(User).where(User.id == user_id)
)
).scalar_one_or_none()
engineer_name = (
engineer_user.name
if engineer_user and engineer_user.name
else "Unknown"
)
target_user_ids = (
[session.escalated_to_id] if session.escalated_to_id else None
)
await notify(
"session.escalated",
handoff.account_id,
{
"session_id": str(handoff.session_id),
"engineer_name": engineer_name,
"escalation_reason": handoff.engineer_notes or "",
"problem_summary": session.problem_summary or "N/A",
# Surface the PSA ticket id in the bell-icon title so two
# similarly-worded escalations are still distinguishable
# at a glance.
"psa_ticket_id": session.psa_ticket_id,
},
self.db,
target_user_ids=target_user_ids,
)
except Exception:
logger.exception(
"notify() dispatch failed for handoff %s", handoff.id
)
return documentation, psa_result
async def _build_enhanced_escalation_package(
self,
session: AISession,
user_id: UUID,
) -> dict[str, Any]:
"""Lazy wrapper around the legacy enhanced-package builder.
The builder lives in flowpilot_engine; we only need it for the
escalate path. Failures are caught here so handoff creation never
depends on the optional Sonnet enrichment — return the minimal
shape on failure.
"""
try:
from app.services.flowpilot_engine import (
_build_escalation_package_enhanced,
)
return await _build_escalation_package_enhanced(session, user_id)
except Exception:
logger.exception(
"Enhanced escalation package build failed for session %s; "
"falling back to minimal package",
session.id,
)
return {}
async def dispatch_escalation_notifications(
self, handoff: SessionHandoff
) -> int:
"""Email engineer-or-admin users in the account about a new escalation.
Call this AFTER `db.commit()` has succeeded — sending email for a
rolled-back handoff is the kind of trust-erosion bug that makes pilot
customers stop trusting the tool. Returns the number of recipients
successfully emailed (best-effort, not authoritative).
Failures are logged but never raise: the wedge demo's reliability
story is "handoff creation always succeeds; notification is best-effort,"
not "handoff creation depends on the email service being up." This is
the graceful-degradation regression the eng + codex reviews both
flagged as critical.
Per-channel delivery records (Codex correction on the dead
`notification_sent` boolean) are a v1.x story — for now the
application logs are the audit trail.
"""
if handoff.intent != "escalate":
return 0
# Publish to the in-memory bus first so connected senior-tech inboxes
# see the new card slide in within ~1s of escalate. This path is
# fire-and-forget (no IO, just memory) so it can sit ahead of the
# email fan-out.
try:
await escalation_bus.publish(
handoff.account_id,
{
"type": "handoff_created",
"handoff_id": str(handoff.id),
"session_id": str(handoff.session_id),
"priority": handoff.priority,
"engineer_notes": handoff.engineer_notes or "",
"created_at": handoff.created_at.isoformat()
if handoff.created_at
else None,
},
)
except Exception:
logger.exception(
"EscalationBus publish failed for handoff %s", handoff.id
)
try:
recipients = (
await self.db.execute(
select(User).where(
User.account_id == handoff.account_id,
User.id != handoff.handed_off_by,
User.account_role.in_(("owner", "admin", "engineer")),
User.is_active.is_(True),
User.deleted_at.is_(None),
)
)
).scalars().all()
if not recipients:
logger.info(
"No notification recipients for handoff %s in account %s",
handoff.id,
handoff.account_id,
)
return 0
# Pull session for the email subject. Fall back to a generic title
# if the session is gone (e.g. cascade delete mid-dispatch).
session_result = await self.db.execute(
select(AISession).where(AISession.id == handoff.session_id)
)
session = session_result.scalar_one_or_none()
problem = (
session.problem_summary if session and session.problem_summary
else "an active session"
)
title = f"New escalation: {problem}"
notes = (handoff.engineer_notes or "").strip()
body = (
"A teammate has escalated a session and is asking for help.\n\n"
f"Reason: {notes if notes else 'No reason provided.'}\n"
f"Priority: {handoff.priority}"
)
link_url = (
f"{settings.FRONTEND_URL.rstrip('/')}/escalations"
if settings.FRONTEND_URL
else None
)
results = await asyncio.gather(
*[
EmailService.send_notification_email(
to_email=r.email,
title=title,
body=body,
link_url=link_url,
)
for r in recipients
],
return_exceptions=True,
)
sent = sum(1 for r in results if r is True)
logger.info(
"Escalation notifications dispatched for handoff %s: %d/%d recipients",
handoff.id,
sent,
len(recipients),
)
return sent
except Exception:
logger.exception(
"Escalation notification dispatch failed for handoff %s",
handoff.id,
)
return 0
async def _generate_snapshot(self, session: AISession) -> dict[str, Any]:
"""Generate a snapshot of the session state at handoff time."""
snapshot: dict[str, Any] = {
@@ -125,16 +430,56 @@ class HandoffManager:
handoff_id: UUID,
claiming_user_id: UUID,
) -> SessionHandoff:
"""Claim a handed-off session."""
"""Claim a handed-off session.
If the handoff was already claimed by a *different* user (the race
story: two seniors clicking Pick Up simultaneously), raise
`HandoffAlreadyClaimedError` with the winning claimer's details so
the API can return 409 with the data the loser's toast needs. A
re-claim by the same user is idempotent.
"""
claiming_user_id = UUID(str(claiming_user_id))
claimed_at = datetime.now(timezone.utc)
update_result = await self.db.execute(
update(SessionHandoff)
.where(
SessionHandoff.id == handoff_id,
SessionHandoff.claimed_by.is_(None),
SessionHandoff.handed_off_by != claiming_user_id,
)
.values(claimed_by=claiming_user_id, claimed_at=claimed_at)
.returning(SessionHandoff.id)
)
claimed_now = update_result.scalar_one_or_none() is not None
result = await self.db.execute(
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
select(SessionHandoff)
.options(
selectinload(SessionHandoff.claimed_by_user),
selectinload(SessionHandoff.handed_off_by_user),
)
.where(SessionHandoff.id == handoff_id)
)
handoff = result.scalar_one_or_none()
if not handoff:
raise ValueError(f"Handoff {handoff_id} not found")
handoff.claimed_by = claiming_user_id
handoff.claimed_at = datetime.now(timezone.utc)
handed_off_by = UUID(str(handoff.handed_off_by))
claimed_by = (
UUID(str(handoff.claimed_by)) if handoff.claimed_by is not None else None
)
if handed_off_by == claiming_user_id:
raise PermissionError("Cannot claim your own handoff")
if not claimed_now and claimed_by != claiming_user_id:
claimer = handoff.claimed_by_user
raise HandoffAlreadyClaimedError(
claimed_by_id=claimed_by,
claimed_by_name=claimer.name if claimer else "another engineer",
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
)
# Reactivate session
session_result = await self.db.execute(
@@ -149,43 +494,111 @@ class HandoffManager:
await self.db.flush()
return handoff
async def _generate_ai_assessment(
async def _generate_handoff_summary(
self, session: AISession
) -> tuple[str | None, dict[str, Any] | None]:
"""Generate AI diagnostic assessment for escalation handoffs."""
) -> dict[str, Any] | None:
"""Single structured AI call for the escalation magic-moment screen.
Returns a dict with summary_prose, what_we_know, likely_cause,
suggested_steps, and confidence. Returns None on timeout or error.
Replaces the old _generate_ai_assessment + _generate_ai_assessment_with_timeout
pair, which returned freeform prose with no usable structured fields.
"""
timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS
try:
from app.services.assistant_chat_service import _call_ai
context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}"
msgs = session.conversation_messages or []
# Include last 10 messages for context
recent = "\n".join(
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
for m in msgs[-10:]
return await asyncio.wait_for(
self._generate_handoff_summary_inner(session),
timeout=timeout,
)
assessment_text, _, _ = await _call_ai(
system_base="You are a diagnostic assessment generator for MSP escalations.",
rag_context="",
history=[],
new_message=(
f"Generate a brief diagnostic assessment for this escalation.\n"
f"{context}\n\nRecent conversation:\n{recent}\n\n"
f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)"
),
max_tokens=500,
except asyncio.TimeoutError:
logger.warning(
"Handoff summary timed out after %ss for session %s",
timeout,
session.id,
)
assessment_data = {
"likely_cause": "See assessment text",
"suggested_steps": [],
"confidence": "medium",
}
return assessment_text, assessment_data
return None
except Exception:
logger.exception("Failed to generate AI assessment")
return None, None
logger.exception(
"Handoff summary failed for session %s", session.id
)
return None
async def _generate_handoff_summary_inner(
self, session: AISession
) -> dict[str, Any]:
steps = session.steps or []
steps_tried = []
for step in sorted(steps, key=lambda s: s.step_order):
content = step.content or {}
text = content.get("text", "").strip()
if not text:
continue
entry = text
if step.selected_option:
entry += f"{step.selected_option}"
elif step.free_text_input:
entry += f"{step.free_text_input[:100]}"
elif step.was_skipped:
entry += " (skipped)"
steps_tried.append(entry)
steps_text = (
"\n".join(f"- {s}" for s in steps_tried[:15])
or "No diagnostic steps recorded."
)
msgs = session.conversation_messages or []
recent_msgs = "\n".join(
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
for m in msgs[-10:]
)
prompt = (
"Generate a structured escalation handoff summary.\n\n"
f"Problem: {session.problem_summary or 'Unknown'}\n"
f"Domain: {session.problem_domain or 'Unknown'}\n"
f"Escalation reason: {session.escalation_reason or 'Not provided'}\n\n"
f"Diagnostic steps taken:\n{steps_text}\n\n"
f"Recent conversation:\n{recent_msgs}\n\n"
"Respond with ONLY a valid JSON object matching this schema exactly:\n"
'{"summary_prose": "<2-3 sentences suitable for PSA ticket notes>",\n'
' "what_we_know": ["<confirmed fact 1>", "<confirmed fact 2>"],\n'
' "likely_cause": "<one sentence root cause hypothesis>",\n'
' "suggested_steps": ["<next step 1>", "<next step 2>"],\n'
' "confidence": "<low or medium or high>"}'
)
provider = get_ai_provider(settings.get_model_for_action("escalation_package"))
raw, _, _ = await provider.generate_json(
system_prompt=(
"You are a diagnostic assessment generator for MSP tech support escalations. "
"Always respond with valid JSON and nothing else. "
"Be concise and factual."
),
messages=[{"role": "user", "content": prompt}],
max_tokens=700,
)
cleaned = raw.strip()
if cleaned.startswith("```"):
lines = cleaned.split("\n", 1)
cleaned = lines[1] if len(lines) > 1 else cleaned
if cleaned.endswith("```"):
cleaned = cleaned[:-3].rstrip()
result = json.loads(cleaned)
if not isinstance(result.get("suggested_steps"), list):
result["suggested_steps"] = []
if not isinstance(result.get("what_we_know"), list):
result["what_we_know"] = []
if result.get("confidence") not in ("low", "medium", "high"):
result["confidence"] = "medium"
if not isinstance(result.get("summary_prose"), str) or not result.get("summary_prose"):
result["summary_prose"] = result.get("likely_cause", "Assessment generated.")
if not isinstance(result.get("likely_cause"), str):
result["likely_cause"] = ""
return result
async def generate_briefing(
self, handoff_id: UUID, claiming_user_id: UUID
@@ -288,3 +701,105 @@ class HandoffManager:
})
return queue_items
async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
"""Run the AI enrichment for an escalation handoff in the background.
Scheduled by `/escalate` and `/handoff` (intent=escalate) endpoints via
FastAPI BackgroundTasks. Opens its own DB session because the request
session is closed by the time this runs. Generates:
1. The legacy AI-enhanced escalation_package (Sonnet, ~5-10s) — saved
to `session.escalation_package`, preserving the `intent` /
`engineer_notes` / `handoff_id` keys the dual-write set so legacy
consumers keep working.
2. The diagnostic AI assessment (Sonnet, ~4-15s) — saved to
`handoff.ai_assessment` and `handoff.ai_assessment_data`.
On completion publishes a `handoff_assessment_ready` event on the
escalation bus so any connected magic-moment screen can refresh
without a manual reload. Failures are logged but never propagated —
the click-path-side handoff creation already committed, so worst case
the senior sees the "Assessment still computing" placeholder until
they refresh manually.
"""
from app.core.database import async_session_maker
from app.core.escalation_bus import bus as escalation_bus
async with async_session_maker() as db:
try:
result = await db.execute(
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
)
handoff = result.scalar_one_or_none()
if not handoff or handoff.intent != "escalate":
return
session_result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps), selectinload(AISession.user))
.where(AISession.id == handoff.session_id)
)
session = session_result.scalar_one_or_none()
if not session:
logger.warning(
"enrich_escalation_async: session %s gone for handoff %s",
handoff.session_id,
handoff_id,
)
return
manager = HandoffManager(db)
# Single consolidated AI call — replaces the old
# _generate_ai_assessment + _build_enhanced_escalation_package pair.
try:
summary = await manager._generate_handoff_summary(session)
if summary:
# ai_assessment (text) holds the PSA prose for backward compat
# (push_to_psa reads it; generate_status_update falls back to it).
handoff.ai_assessment = summary.get("summary_prose")
handoff.ai_assessment_data = summary
# Keep suggested_next_steps in escalation_package so
# psa_documentation_service can read it without a handoff join.
existing_pkg = (
session.escalation_package
if isinstance(session.escalation_package, dict)
else {}
)
session.escalation_package = {
**existing_pkg,
"suggested_next_steps": summary.get("suggested_steps", []),
}
except Exception:
logger.exception(
"enrich_escalation_async: summary generation failed for handoff %s",
handoff_id,
)
await db.commit()
try:
await escalation_bus.publish(
handoff.account_id,
{
"type": "handoff_assessment_ready",
"handoff_id": str(handoff.id),
"session_id": str(handoff.session_id),
"has_assessment": handoff.ai_assessment_data is not None,
},
)
except Exception:
logger.exception(
"enrich_escalation_async: bus publish failed for handoff %s",
handoff_id,
)
except Exception:
logger.exception(
"enrich_escalation_async failed for handoff %s", handoff_id
)
try:
await db.rollback()
except Exception:
pass

View File

@@ -371,13 +371,35 @@ async def _send_teams_message(
def _build_notification_title(event: str, payload: dict[str, Any]) -> str:
"""Human-readable title per event type."""
titles = {
"session.escalated": "Session escalated by {engineer_name}",
# Distinguishability matters in the bell panel: with a generic title
# ("Session escalated by Jane") two different escalations from the
# same junior look like a duplicate notification. Including a short
# problem snippet (and ticket number if present) lets the senior
# tell them apart at a glance.
"session.escalated": "Escalation from {engineer_name}{ticket_suffix}: {problem_snippet}",
"session.high_priority": "High-priority session started: {ticket_number}",
"proposal.pending": "New flow proposal: {title}",
"proposal.approved": "Flow proposal approved: {title}",
"knowledge_gap.detected": "Knowledge gap detected: {gap_type}",
"test": "Test Notification from ResolutionFlow",
}
# Build the escalation-specific derived fields. Done here rather than at
# the call site so every dispatch path (legacy /escalate shim, /handoff,
# any future entry point) gets consistent formatting without each one
# having to repeat the snippet logic.
if event == "session.escalated":
problem = (payload.get("problem_summary") or "").strip()
if not problem or problem.upper() == "N/A":
problem_snippet = "(no summary provided)"
elif len(problem) > 70:
problem_snippet = problem[:67].rstrip() + ""
else:
problem_snippet = problem
ticket = payload.get("psa_ticket_id") or payload.get("ticket_number")
ticket_suffix = f" · #{ticket}" if ticket else ""
payload = {**payload, "problem_snippet": problem_snippet, "ticket_suffix": ticket_suffix}
template = titles.get(event, f"Notification: {event}")
try:
return template.format(**payload)
@@ -405,7 +427,12 @@ def _build_notification_body(event: str, payload: dict[str, Any]) -> str:
def _build_notification_link(event: str, payload: dict[str, Any]) -> Optional[str]:
"""In-app link per event type. Returns path (no host)."""
links: dict[str, str] = {
"session.escalated": "/pilot/{session_id}",
# ?pickup=true triggers the senior-tech handoff/pickup flow on the
# session page (magic-moment screen for handoff-based escalations,
# legacy SessionBriefing for `requesting_escalation` sessions).
# Without it the senior lands on a session-detail GET they can't
# access pre-pickup, which the user perceives as a dead notification.
"session.escalated": "/pilot/{session_id}?pickup=true",
"session.high_priority": "/pilot/{session_id}",
"proposal.pending": "/review-queue",
"proposal.approved": "/review-queue",

View File

@@ -12,6 +12,10 @@ from app.services.psa.types import (
PSAConfiguration,
PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
@@ -28,7 +32,7 @@ class AutotaskProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Autotask integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
raise NotImplementedError("Autotask integration coming soon")
async def post_note(
@@ -74,3 +78,18 @@ class AutotaskProvider(PSAProvider):
work_type: str | None = None,
) -> PSATimeEntry:
raise NotImplementedError("Autotask integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Autotask integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Autotask integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Autotask integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Autotask integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -13,6 +13,10 @@ from .types import (
PSAConfiguration,
PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
@@ -28,7 +32,7 @@ class PSAProvider(ABC):
...
@abstractmethod
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
...
@abstractmethod
@@ -83,3 +87,23 @@ class PSAProvider(ABC):
work_type: str | None = None,
) -> PSATimeEntry:
...
@abstractmethod
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
...
@abstractmethod
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
...
@abstractmethod
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
...
@abstractmethod
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
...
@abstractmethod
async def list_priorities(self) -> list[dict]:
...

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from app.services.psa.base import PSAProvider
from app.services.psa.cache import psa_cache
from app.services.psa.exceptions import PSAError
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
@@ -17,6 +18,10 @@ from app.services.psa.types import (
PSAConfiguration,
PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
from .client import ConnectWiseClient
@@ -55,27 +60,31 @@ class ConnectWiseProvider(PSAProvider):
)
return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
unassigned, board_ids, page, and page_size filters."""
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier,
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "id desc",
"orderBy": "priority/sort asc,dateEntered desc",
"pageSize": page_size,
"page": page,
}
# Build CW condition query
conditions: list[str] = []
if query:
conditions.append(f"summary contains '{query}'")
# Sanitize: strip single quotes to prevent CW condition injection
safe_query = query.replace("'", "")
conditions.append(f"summary contains '{safe_query}'")
if filters.get("board_id"):
conditions.append(f"board/id = {filters['board_id']}")
if filters.get("status_id"):
conditions.append(f"status/id = {filters['status_id']}")
elif filters.get("status_name"):
safe_status = str(filters["status_name"]).replace("'", "")
conditions.append(f"status/name = '{safe_status}'")
if not filters.get("include_closed", False):
conditions.append("closedFlag = false")
if filters.get("member_identifier") is not None:
@@ -86,16 +95,27 @@ class ConnectWiseProvider(PSAProvider):
if board_ids:
board_list = ", ".join(str(bid) for bid in board_ids)
conditions.append(f"board/id in ({board_list})")
if filters.get("company_id"):
conditions.append(f"company/id = {int(filters['company_id'])}")
if conditions:
params["conditions"] = " and ".join(conditions)
condition_str = " and ".join(conditions) if conditions else ""
if condition_str:
params["conditions"] = condition_str
data = await self.client.get("/service/tickets", params=params)
count_params: dict = {}
if condition_str:
count_params["conditions"] = condition_str
return [
self._map_ticket(t)
for t in (data if isinstance(data, list) else [])
]
# Fire page fetch + count in parallel
data, count_data = await asyncio.gather(
self.client.get("/service/tickets", params=params),
self.client.get("/service/tickets/count", params=count_params),
)
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
async def get_ticket_configurations(
self, ticket_id: str
@@ -246,13 +266,30 @@ class ConnectWiseProvider(PSAProvider):
async def update_ticket_status(
self, ticket_id: str, status_id: int
) -> PSATicket:
"""Update a CW ticket's status using JSON Patch format."""
"""Update a CW ticket's status using JSON Patch format.
Verifies CW actually applied the change — CW silently returns 200 when
a status id is invalid for the ticket's board. We check the response
body's status.id matches what we sent, and raise PSAError if not.
"""
patch_body = [
{"op": "replace", "path": "status", "value": {"id": status_id}}
]
data = await self.client.patch(
f"/service/tickets/{ticket_id}", json_body=patch_body
)
applied = (data.get("status") or {}) if isinstance(data, dict) else {}
applied_id = applied.get("id")
if applied_id != status_id:
logger.warning(
"CW status PATCH for ticket %s returned status id=%s instead of %s",
ticket_id, applied_id, status_id,
)
raise PSAError(
f"ConnectWise did not apply status {status_id} "
f"(still {applied.get('name') or applied_id}). "
"The status may not be valid for this ticket's board."
)
return self._map_ticket(data)
async def list_members(self) -> list[PSAMember]:
@@ -591,16 +628,247 @@ class ConnectWiseProvider(PSAProvider):
@staticmethod
def _map_ticket(data: dict) -> PSATicket:
"""Map a CW ticket JSON dict to a PSATicket."""
company = data.get("company") or {}
board = data.get("board") or {}
status = data.get("status") or {}
priority = data.get("priority") or {}
return PSATicket(
id=str(data["id"]),
id=str(data.get("id", "")),
summary=data.get("summary", ""),
company_name=data.get("company", {}).get("name"),
company_id=str(data["company"]["id"]) if data.get("company") else None,
board_name=data.get("board", {}).get("name"),
board_id=data.get("board", {}).get("id"),
status_name=data.get("status", {}).get("name"),
status_id=data.get("status", {}).get("id"),
priority_name=data.get("priority", {}).get("name"),
priority_id=data.get("priority", {}).get("id"),
company_name=company.get("name"),
company_id=str(company.get("id")) if company.get("id") else None,
board_name=board.get("name"),
board_id=board.get("id"),
status_name=status.get("name"),
status_id=status.get("id"),
priority_name=priority.get("name"),
priority_id=priority.get("id"),
closed=data.get("closedFlag", False),
)
# ── Resource management ───────────────────────────────────────────
# Schedule type id for "Service Ticket" resources — CW's canonical type for ticket co-assignees
_SCHEDULE_TYPE_SERVICE_TICKET = 4
async def _get_ticket_owner(self, ticket_id: int) -> dict | None:
"""Fetch the ticket's current owner (MemberReference) or None if unassigned."""
data = await self.client.get(
f"/service/tickets/{ticket_id}",
params={"fields": "id,owner"},
)
if not isinstance(data, dict):
return None
owner_raw = data.get("owner")
return owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None
async def _list_ticket_schedule_entries(self, ticket_id: int) -> list[dict]:
"""List schedule entries for a ticket's co-assignees.
Returns raw CW schedule entry dicts with at least id and member info.
"""
data = await self.client.get(
"/schedule/entries",
params={
"conditions": (
f"type/id={self._SCHEDULE_TYPE_SERVICE_TICKET} AND objectId={ticket_id}"
),
"fields": "id,member,name",
"pageSize": 100,
},
)
return data if isinstance(data, list) else []
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
"""List members assigned to a CW ticket.
Merges the `owner` MemberReference (primary assignee) with schedule entries
of type 4 (Service Ticket resources — co-assignees). Deduped by member id.
"""
owner = await self._get_ticket_owner(ticket_id)
entries = await self._list_ticket_schedule_entries(ticket_id)
members = await self.list_members()
by_id = {str(m.id): m for m in members}
seen_ids: set[str] = set()
results: list[PSAResource] = []
if owner is not None:
owner_id = str(owner.get("id"))
m = by_id.get(owner_id)
if m:
results.append(PSAResource(
member_id=int(m.id),
member_name=m.name,
member_identifier=m.identifier,
))
else:
results.append(PSAResource(
member_id=int(owner.get("id") or 0),
member_name=str(owner.get("name") or ""),
member_identifier=str(owner.get("identifier") or ""),
))
seen_ids.add(owner_id)
for entry in entries:
entry_member = entry.get("member") if isinstance(entry, dict) else None
if not isinstance(entry_member, dict):
continue
mid = str(entry_member.get("id") or "")
if not mid or mid in seen_ids:
continue
m = by_id.get(mid)
if m:
results.append(PSAResource(
member_id=int(m.id),
member_name=m.name,
member_identifier=m.identifier,
))
else:
results.append(PSAResource(
member_id=int(entry_member.get("id") or 0),
member_name=str(entry_member.get("name") or ""),
member_identifier=str(entry_member.get("identifier") or ""),
))
seen_ids.add(mid)
return results
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
"""Assign a member to a CW ticket.
- If the ticket has no owner, set the target as `owner` (CW's canonical
primary assignee field). CW typically mirrors this into the derived
`resources` string automatically.
- If the ticket is already owned by someone else, add the target as a
co-assignee via a schedule entry of type 4 (Service Ticket). The
existing owner is not changed.
- Idempotent when target is already owner or already has a schedule entry.
"""
members = await self.list_members()
target = next((m for m in members if str(m.id) == str(member_id)), None)
if target is None:
raise PSAError(f"Member {member_id} not found")
current_owner = await self._get_ticket_owner(ticket_id)
if current_owner is None:
# Primary assign — set owner
await self.client.patch(
f"/service/tickets/{ticket_id}",
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
)
elif str(current_owner.get("id")) != str(target.id):
# Ticket owned by someone else — add as co-assignee via schedule entry.
# Idempotent: skip if a schedule entry already exists for this member.
existing = await self._list_ticket_schedule_entries(ticket_id)
already_assigned = any(
str((e.get("member") or {}).get("id") or "") == str(target.id)
for e in existing
)
if not already_assigned:
await self.client.post(
"/schedule/entries",
json_body={
"member": {"id": int(target.id)},
"objectId": int(ticket_id),
"type": {"id": self._SCHEDULE_TYPE_SERVICE_TICKET},
"name": target.name or target.identifier or f"Member {target.id}",
},
)
# else: already the owner — idempotent no-op
return PSAResource(
member_id=int(target.id),
member_name=target.name,
member_identifier=target.identifier,
)
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
"""Remove a member from a CW ticket (idempotent).
- If the target is the current owner, clear the owner field.
- Otherwise, delete their schedule entry (Service Ticket type).
"""
members = await self.list_members()
target = next((m for m in members if str(m.id) == str(member_id)), None)
if target is None:
return
current_owner = await self._get_ticket_owner(ticket_id)
if current_owner is not None and str(current_owner.get("id")) == str(target.id):
# Unassign the owner. Try RFC 6902 "remove" first; fall back to
# "replace" with null if CW rejects it.
try:
await self.client.patch(
f"/service/tickets/{ticket_id}",
json_body=[{"op": "remove", "path": "owner"}],
)
except PSAError:
await self.client.patch(
f"/service/tickets/{ticket_id}",
json_body=[{"op": "replace", "path": "owner", "value": None}],
)
return
# Not the owner — find and delete the schedule entry for this member.
entries = await self._list_ticket_schedule_entries(ticket_id)
for entry in entries:
entry_member = entry.get("member") if isinstance(entry, dict) else None
if isinstance(entry_member, dict) and str(entry_member.get("id") or "") == str(target.id):
entry_id = entry.get("id")
if entry_id:
await self.client.delete(f"/schedule/entries/{entry_id}")
break
# ── Ticket creation ───────────────────────────────────────────────
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
"""Create a new CW service ticket."""
body: dict = {
"summary": payload.summary,
"board": {"id": payload.board_id},
"company": {"id": payload.company_id},
"status": {"id": payload.status_id},
"priority": {"id": payload.priority_id},
}
if payload.description:
body["initialDescription"] = payload.description
if payload.assigned_member_id:
body["owner"] = {"id": payload.assigned_member_id}
data = await self.client.post("/service/tickets", json_body=body)
ticket_id = data.get("id") if isinstance(data, dict) else None
resources: list[PSAResource] = []
if ticket_id and payload.assigned_member_id:
try:
resources = await self.list_resources(ticket_id)
except Exception:
pass
company = (data.get("company") or {}) if isinstance(data, dict) else {}
board = (data.get("board") or {}) if isinstance(data, dict) else {}
status = (data.get("status") or {}) if isinstance(data, dict) else {}
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
return PSACreatedTicket(
id=ticket_id or 0,
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
board_name=board.get("name", ""),
status_name=status.get("name", ""),
priority_name=priority.get("name", ""),
company_name=company.get("name", ""),
resources=resources,
)
# ── Priorities ────────────────────────────────────────────────────
async def list_priorities(self) -> list[dict]:
"""List CW service priorities."""
data = await self.client.get("/service/priorities", params={"pageSize": 50})
return [
{"id": p.get("id"), "name": p.get("name")}
for p in (data if isinstance(data, list) else [])
]

View File

@@ -12,6 +12,10 @@ from app.services.psa.types import (
PSAConfiguration,
PSATimeEntry,
PSABoard,
PaginatedTicketResult,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
@@ -28,7 +32,7 @@ class HaloPSAProvider(PSAProvider):
async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
raise NotImplementedError("Halo PSA integration coming soon")
async def post_note(
@@ -74,3 +78,18 @@ class HaloPSAProvider(PSAProvider):
work_type: str | None = None,
) -> PSATimeEntry:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
raise NotImplementedError("Halo PSA integration coming soon")
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
raise NotImplementedError("Halo PSA integration coming soon")
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
raise NotImplementedError("Halo PSA integration coming soon")
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_priorities(self) -> list[dict]:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -73,6 +73,40 @@ class PSABoard(BaseModel):
inactive: bool = False
class PaginatedTicketResult(BaseModel):
items: list[PSATicket]
total: int
page: int
page_size: int
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSACreatedTicket(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource] = []
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class NoteType:
INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution"

View File

@@ -83,6 +83,10 @@ state means the engineer resolved the issue another way; the note should cover \
that actual resolution, not just the failed attempt.
- applied_partial: Note that the fix was partially applied. If partial_notes \
are provided, include them. Then describe the final resolution path taken.
- applied_pending: Note that the fix was applied and verification is pending. \
If pending_reason is provided, include it (e.g. "awaiting client power-cycle"). \
Frame the resolution as provisional — the fix is in place but not yet \
confirmed. Do not write closure language.
- dismissed: Treat the fix as considered and set aside. Do not center the note \
on it. Describe the resolution based on what was actually confirmed and done.
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
@@ -322,6 +326,8 @@ class ResolutionNoteGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.pending_reason:
lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")

View File

@@ -5,6 +5,7 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.ai_session import AISession
from app.models.session_resolution_output import SessionResolutionOutput
@@ -21,7 +22,9 @@ class ResolutionOutputGenerator:
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
result = await self.db.execute(
select(AISession).where(AISession.id == session_id)
select(AISession)
.options(selectinload(AISession.steps))
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:

View File

@@ -360,6 +360,7 @@ async def save_to_library(
category_id: UUID | None,
share_with_team: bool,
user_id: UUID,
account_id: UUID,
team_id: UUID | None,
script_body: str | None = None,
parameters_schema: dict | None = None,
@@ -401,6 +402,7 @@ async def save_to_library(
id=uuid_mod.uuid4(),
category_id=resolved_category_id,
created_by=user_id,
account_id=account_id,
team_id=team_id if share_with_team else None,
name=name,
slug=slug,

View File

@@ -0,0 +1,116 @@
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
from __future__ import annotations
import logging
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
)
from app.services.psa.registry import get_provider_for_account
from app.services.psa.types import TicketCreatePayload
logger = logging.getLogger(__name__)
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
"""Return set of external_member_id ints that are mapped to RF users."""
conn_result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
conn = conn_result.scalar_one_or_none()
if not conn:
return set()
mappings = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
async def list_resources(
account_id: UUID, ticket_id: int, db: AsyncSession
) -> list[PSAResourceSchema]:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resources = await provider.list_resources(ticket_id)
return [
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in resources
]
async def add_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> PSAResourceSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resource = await provider.add_resource(ticket_id, member_id)
return PSAResourceSchema(
member_id=resource.member_id,
member_name=resource.member_name,
member_identifier=resource.member_identifier,
is_rf_user=resource.member_id in mapped_ids,
)
async def remove_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> None:
provider = await get_provider_for_account(account_id, db)
await provider.remove_resource(ticket_id, member_id)
async def update_status(
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
) -> PSATicketStatusUpdateSchema:
provider = await get_provider_for_account(account_id, db)
# get current status before updating
ticket = await provider.get_ticket(str(ticket_id))
previous_status = ticket.status_name or ""
await provider.update_ticket_status(str(ticket_id), status_id)
# get new status name from statuses list
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
return PSATicketStatusUpdateSchema(
ticket_id=ticket_id,
previous_status=previous_status,
new_status=new_status,
new_status_id=status_id,
)
async def create_ticket(
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
) -> PSATicketCreatedSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
result = await provider.create_ticket(payload)
return PSATicketCreatedSchema(
id=result.id,
summary=result.summary,
board_name=result.board_name,
status_name=result.status_name,
priority_name=result.priority_name,
company_name=result.company_name,
resources=[
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in result.resources
],
)

View File

@@ -583,10 +583,14 @@ async def send_chat_message(
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
"""
from sqlalchemy import or_
result = await db.execute(
select(AISession).where(
AISession.id == session_id,
AISession.user_id == user_id,
or_(
AISession.user_id == user_id,
AISession.escalated_to_id == user_id,
),
AISession.session_type == "chat",
)
)

View File

@@ -27,6 +27,7 @@ markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
unit: marks tests as unit tests
rls: opt-in RLS migration and policy tests (run with RUN_RLS_TESTS=1)
# Ignore paths
testpaths = tests
@@ -34,6 +35,9 @@ testpaths = tests
# Warnings
filterwarnings =
error
ignore:unclosed <socket\.socket.*:ResourceWarning
ignore:unclosed transport .*:ResourceWarning
ignore:unclosed event loop .*:ResourceWarning
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::pluggy.PluggyTeardownRaisedWarning

View File

@@ -1,11 +1,12 @@
# Include production dependencies
-r requirements.txt
# Testing
pytest==7.4.3
pytest-asyncio==0.23.0
# Testing — pytest-asyncio 0.24+ requires pytest>=8.2
pytest==8.4.2
pytest-asyncio==0.24.0
pytest-xdist==3.6.1
httpx>=0.27.0
pytest-cov==4.1.0
pytest-cov==5.0.0
# Code quality
black==24.1.1

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
Seed Phase 9 QA fixtures: 4 ai_sessions + matching suggested_fixes that
exercise the five Phase 9 components which gate on a backend-emitted
`SUGGEST_FIX` action and don't fire reliably in normal local sessions.
Usage:
cd backend
python -m scripts.seed_phase9_qa_fixtures
python -m scripts.seed_phase9_qa_fixtures --reset # delete & recreate
Targets the super-admin from `seed_test_users.py`
(admin@resolutionflow.example.com) and their account. UUIDs are
deterministic (UUID5 over a fixed namespace) so re-runs are idempotent
without --reset.
Sessions created:
| # | Title | Phase 9 component reached when… |
|---|---------------------------------|-------------------------------------------------------|
| A | Phase 9 QA — no-template path | ChatTabStrip + ScriptBuilderTab + ProposalBanner |
| B | Phase 9 QA — drafted-script | InlineNoTemplateDialog + ProposalBanner |
| C | Phase 9 QA — template match | TemplateMatchPanel + ProposalBanner |
| D | Phase 9 QA — verify state | EscalateInterceptDialog (with new "partial" choice) |
Run /qa, then in the browser go to /pilot, click each session in the
sidebar, and exercise its Phase 9 surface. The session URLs are printed
at the end.
"""
import argparse
import asyncio
import sys
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
ADMIN_EMAIL = "admin@resolutionflow.example.com"
# Deterministic UUIDs so re-running the seeder updates rather than duplicates.
NS = uuid.UUID("00000000-0000-0000-0000-000000000901")
SESSION_A = uuid.uuid5(NS, "session-A-no-template")
SESSION_B = uuid.uuid5(NS, "session-B-drafted-script")
SESSION_C = uuid.uuid5(NS, "session-C-template-match")
SESSION_D = uuid.uuid5(NS, "session-D-verify-state")
FIX_A = uuid.uuid5(NS, "fix-A")
FIX_B = uuid.uuid5(NS, "fix-B")
FIX_C = uuid.uuid5(NS, "fix-C")
FIX_D = uuid.uuid5(NS, "fix-D")
CATEGORY_QA = uuid.uuid5(NS, "category-qa-fixtures")
TEMPLATE_QA = uuid.uuid5(NS, "template-qa-fixtures")
DRAFTED_SCRIPT = """\
# Phase 9 QA fixture — AI-drafted PowerShell to flush DNS and
# restart the FortiClient service. Not for production use.
ipconfig /flushdns
Restart-Service -Name "FortiSslvpnDaemon" -Force
Get-Service -Name "FortiSslvpnDaemon" | Format-Table -AutoSize
"""
TEMPLATE_BODY = """\
# Phase 9 QA fixture — canned template that the AI matches against.
param([string]$ServiceName = "FortiSslvpnDaemon")
Restart-Service -Name $ServiceName -Force
Get-Service -Name $ServiceName | Select-Object Status, Name
"""
async def main(reset: bool = False) -> None:
db_url = (
settings.ADMIN_DATABASE_URL
if hasattr(settings, "ADMIN_DATABASE_URL") and settings.ADMIN_DATABASE_URL
else settings.DATABASE_URL
)
engine = create_async_engine(db_url, echo=False)
now = datetime.now(timezone.utc)
async with engine.begin() as conn:
# ─── Locate the admin user + account ───────────────────────────
row = (
await conn.execute(
text(
"SELECT id, account_id FROM users WHERE email = :email LIMIT 1"
),
{"email": ADMIN_EMAIL},
)
).first()
if row is None:
print(
f"ERROR: user {ADMIN_EMAIL!r} not found. Run "
"`python -m scripts.seed_test_users` first.",
file=sys.stderr,
)
sys.exit(2)
user_id, account_id = row
if reset:
await conn.execute(
text(
"DELETE FROM session_suggested_fixes WHERE id = ANY(:ids)"
),
{"ids": [FIX_A, FIX_B, FIX_C, FIX_D]},
)
await conn.execute(
text("DELETE FROM ai_sessions WHERE id = ANY(:ids)"),
{"ids": [SESSION_A, SESSION_B, SESSION_C, SESSION_D]},
)
await conn.execute(
text("DELETE FROM script_templates WHERE id = :id"),
{"id": TEMPLATE_QA},
)
await conn.execute(
text("DELETE FROM script_categories WHERE id = :id"),
{"id": CATEGORY_QA},
)
# ─── Script category + template (for Session C) ────────────────
await conn.execute(
text(
"""
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
VALUES (:id, 'QA Fixtures', 'qa-fixtures', 999, true, :now, :now)
ON CONFLICT (id) DO NOTHING
"""
),
{"id": CATEGORY_QA, "now": now},
)
await conn.execute(
text(
"""
INSERT INTO script_templates (
id, category_id, account_id, created_by, name, slug,
description, script_body, language, parameters_schema,
default_values, validation_rules, tags, complexity,
requires_elevation, requires_modules, created_at, updated_at
)
VALUES (
:id, :cat_id, :acct_id, :user_id,
'QA Fixture: Restart Forti Service',
'qa-fixture-restart-forti-service',
'Phase 9 QA fixture template for TemplateMatchPanel testing.',
:body, 'powershell',
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '[]'::jsonb,
'beginner', false, '[]'::jsonb,
:now, :now
)
ON CONFLICT (id) DO NOTHING
"""
),
{
"id": TEMPLATE_QA,
"cat_id": CATEGORY_QA,
"acct_id": account_id,
"user_id": user_id,
"body": TEMPLATE_BODY,
"now": now,
},
)
# ─── 4 sessions ────────────────────────────────────────────────
# `canAct` in the chat header gates Resolve/Escalate on
# `messages.length >= 2`, so each fixture seeds two synthetic
# conversation messages — enough to enable the buttons that drive
# the Phase 9 surfaces.
seed_messages = (
'['
'{"role":"user","content":"QA fixture: see seed_phase9_qa_fixtures.py"},'
'{"role":"assistant","content":"This session is a Phase 9 QA fixture. The suggested fix below is pre-seeded — drive it from the UI."}'
']'
)
sessions = [
(SESSION_A, "Phase 9 QA — no-template path"),
(SESSION_B, "Phase 9 QA — drafted-script path"),
(SESSION_C, "Phase 9 QA — template-match path"),
(SESSION_D, "Phase 9 QA — verify state (Escalate intercept)"),
]
for sid, title in sessions:
await conn.execute(
text(
"""
INSERT INTO ai_sessions (
id, user_id, account_id, session_type, title,
intake_type, intake_content, status, confidence_tier,
confidence_score, conversation_messages,
total_input_tokens, total_output_tokens, step_count,
is_branching, state_version,
handoff_count, total_active_seconds, total_parked_seconds,
created_at, updated_at
)
VALUES (
:id, :user_id, :acct_id, 'chat', :title,
'free_text', '{"text": "QA fixture session"}'::jsonb,
'active', 'discovery',
0.0, (:msgs)::jsonb,
0, 0, 0,
false, 0,
0, 0, 0,
:now, :now
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
status = EXCLUDED.status,
conversation_messages = EXCLUDED.conversation_messages,
updated_at = EXCLUDED.updated_at
"""
),
{
"id": sid,
"user_id": user_id,
"acct_id": account_id,
"title": title,
"msgs": seed_messages,
"now": now,
},
)
# ─── 4 suggested fixes ─────────────────────────────────────────
# Fix A — no template, no draft → ChatTabStrip + ScriptBuilderTab
await _upsert_fix(
conn, fix_id=FIX_A, session_id=SESSION_A, account_id=account_id,
title="Restart the FortiClient daemon and flush DNS",
description=(
"Error -8 on FortiClient SSL VPN typically clears after a "
"service restart on the endpoint. No matching template; "
"no AI draft yet — engineer should choose Build Template "
"or One-Off in the Script Builder tab."
),
confidence_pct=72,
script_template_id=None,
ai_drafted_script=None,
status="proposed",
applied_at=None,
now=now,
)
# Fix B — drafted script, no template → InlineNoTemplateDialog
await _upsert_fix(
conn, fix_id=FIX_B, session_id=SESSION_B, account_id=account_id,
title="Run AI-drafted PowerShell to recover SSL VPN",
description=(
"AI drafted a session-specific script because no library "
"template matched. Inline dialog should offer Save-as-template, "
"Run-once, or Discard."
),
confidence_pct=68,
script_template_id=None,
ai_drafted_script=DRAFTED_SCRIPT,
status="proposed",
applied_at=None,
now=now,
)
# Fix C — template match → TemplateMatchPanel
await _upsert_fix(
conn, fix_id=FIX_C, session_id=SESSION_C, account_id=account_id,
title="Match: QA Fixture Restart Forti Service",
description=(
"AI matched an existing library template. The match panel "
"should render with the parameterization preview and an "
"explicit 'I ran this' action."
),
confidence_pct=88,
script_template_id=TEMPLATE_QA,
ai_drafted_script=None,
status="proposed",
applied_at=None,
now=now,
)
# Fix D — applied_at set, status='proposed' → verify state.
# Hitting Escalate from this state opens EscalateInterceptDialog.
await _upsert_fix(
conn, fix_id=FIX_D, session_id=SESSION_D, account_id=account_id,
title="Verifying: post-apply tunnel reconnect",
description=(
"Engineer marked the fix as Applied; we're now in the "
"verify window. Clicking Escalate from here should open "
"the EscalateInterceptDialog with the four outcome choices "
"(worked / didn't / partial / never-applied)."
),
confidence_pct=80,
script_template_id=None,
ai_drafted_script=DRAFTED_SCRIPT,
status="proposed",
applied_at=now - timedelta(minutes=2),
now=now,
)
await engine.dispose()
print()
print("=" * 64)
print(" Phase 9 QA fixtures ready.")
print("=" * 64)
print()
print(f" Sign in as : {ADMIN_EMAIL}")
print(f" Then visit : http://docker-01:5173/pilot")
print(f" Pick from the History sidebar:")
print(f" A. Phase 9 QA — no-template path (ChatTabStrip + ScriptBuilderTab)")
print(f" B. Phase 9 QA — drafted-script path (InlineNoTemplateDialog)")
print(f" C. Phase 9 QA — template-match path (TemplateMatchPanel)")
print(f" D. Phase 9 QA — verify state (EscalateInterceptDialog)")
print()
print(f" Re-run with --reset to wipe and recreate.")
print()
async def _upsert_fix(
conn,
*,
fix_id: uuid.UUID,
session_id: uuid.UUID,
account_id: uuid.UUID,
title: str,
description: str,
confidence_pct: int,
script_template_id: uuid.UUID | None,
ai_drafted_script: str | None,
status: str,
applied_at: datetime | None,
now: datetime,
) -> None:
await conn.execute(
text(
"""
INSERT INTO session_suggested_fixes (
id, session_id, account_id, title, description,
confidence_pct, script_template_id, ai_drafted_script,
status, applied_at, created_at
)
VALUES (
:id, :sid, :acct, :title, :desc,
:conf, :tmpl, :draft,
:status, :applied, :now
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
confidence_pct = EXCLUDED.confidence_pct,
script_template_id = EXCLUDED.script_template_id,
ai_drafted_script = EXCLUDED.ai_drafted_script,
status = EXCLUDED.status,
applied_at = EXCLUDED.applied_at,
superseded_at = NULL
"""
),
{
"id": fix_id,
"sid": session_id,
"acct": account_id,
"title": title,
"desc": description,
"conf": confidence_pct,
"tmpl": script_template_id,
"draft": ai_drafted_script,
"status": status,
"applied": applied_at,
"now": now,
},
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Seed Phase 9 QA fixtures.")
parser.add_argument(
"--reset",
action="store_true",
help="Delete and recreate the fixtures.",
)
args = parser.parse_args()
asyncio.run(main(reset=args.reset))

View File

@@ -161,8 +161,8 @@ async def main() -> None:
if cfg["plan"] is not None:
await conn.execute(
text("""
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
VALUES (:id, :aid, :plan, 'active', :now, :now)
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
VALUES (:id, :aid, :plan, 'active', false, :now, :now)
"""),
{"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now},
)

View File

@@ -4,8 +4,9 @@ Pytest configuration and fixtures for integration tests.
Provides test database setup, client fixtures, and authentication helpers.
"""
import os
import asyncio
from typing import AsyncGenerator, Generator
from typing import AsyncGenerator
import pytest
import sqlalchemy as sa
from httpx import AsyncClient, ASGITransport
@@ -16,6 +17,14 @@ from app.main import app
from app.core.database import Base, get_db
from app.core.admin_database import get_admin_db
from app.core.config import settings
# Import every model module so all tables are registered with Base.metadata
# before the test_db fixture calls create_all. app.main imports models lazily
# (inside scheduler functions and route modules), which is fine at runtime
# but leaves the metadata incomplete at fixture-setup time — surfacing as
# "relation X does not exist" errors for any model whose route/scheduler
# hasn't been loaded yet. The `from app import models` form avoids
# shadowing the `app` FastAPI instance imported just above.
from app import models as _models # noqa: F401
# Disable invite code requirement for tests
settings.REQUIRE_INVITE_CODE = False
@@ -26,12 +35,64 @@ settings.REQUIRE_INVITE_CODE = False
# would silently nuke the dev database. Only DATABASE_TEST_URL is honored,
# and the safety assertion below refuses to run against a DB whose name
# doesn't contain "test".
import os
TEST_DATABASE_URL = os.environ.get(
_BASE_TEST_DATABASE_URL = os.environ.get(
"DATABASE_TEST_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
)
def _worker_db_url(base_url: str) -> str:
"""Per-worker DB URL for pytest-xdist parallelization.
pytest-xdist sets PYTEST_XDIST_WORKER to 'gw0', 'gw1', ... per worker
process. Each worker needs its own database so the per-test
`DROP SCHEMA public CASCADE` doesn't race across workers. Master/serial
runs (no xdist) keep the base DB. The base DB is created by the postgres
service container; per-worker DBs are CREATE DATABASE-d on first import
by `_ensure_worker_db_exists` below.
"""
worker = os.environ.get("PYTEST_XDIST_WORKER")
if not worker or worker == "master":
return base_url
head, tail = base_url.rsplit("/", 1)
db_name, _, query = tail.partition("?")
suffix = f"?{query}" if query else ""
return f"{head}/{db_name}_{worker}{suffix}"
def _ensure_worker_db_exists(worker_url: str, base_url: str) -> None:
"""Create the per-worker DB if it doesn't exist. Runs synchronously at
conftest import time (before any async test machinery), using psycopg2
against the postgres maintenance DB. No-op when not running under xdist.
"""
if worker_url == base_url:
return
head, tail = worker_url.rsplit("/", 1)
worker_db = tail.partition("?")[0]
# Strip the +asyncpg dialect for sync psycopg2 + connect to 'postgres'.
sync_head = head.replace("+asyncpg", "")
admin_url = f"{sync_head}/postgres"
# Lazy import — psycopg2 is a transitive backend dep; not imported at
# module top to keep the conftest light when xdist isn't in use.
from sqlalchemy import create_engine
engine = create_engine(admin_url, isolation_level="AUTOCOMMIT")
try:
with engine.begin() as conn:
exists = conn.execute(
sa.text("SELECT 1 FROM pg_database WHERE datname = :n"),
{"n": worker_db},
).scalar()
if not exists:
# Identifier interpolation is safe — worker_db is built from
# the trusted base URL + 'gw\d+' worker suffix.
conn.execute(sa.text(f'CREATE DATABASE "{worker_db}"'))
finally:
engine.dispose()
TEST_DATABASE_URL = _worker_db_url(_BASE_TEST_DATABASE_URL)
_ensure_worker_db_exists(TEST_DATABASE_URL, _BASE_TEST_DATABASE_URL)
# Belt-and-suspenders: refuse to run tests against a DB whose name doesn't
# contain "test". Parses the last path segment of the URL (everything after
# the final '/', with query string stripped) so credentials / hosts that
@@ -43,13 +104,41 @@ assert "test" in _test_db_name, (
f"test database (e.g. resolutionflow_test)."
)
_RUN_RLS_TESTS = os.environ.get("RUN_RLS_TESTS") == "1"
_RLS_ISOLATION_FILE = "test_rls_isolation.py"
@pytest.fixture(scope="session")
def event_loop() -> Generator:
"""Create an instance of the default event loop for each test case."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
def pytest_collection_modifyitems(config, items):
"""Keep migration-managed RLS checks out of the default create_all suite."""
if _RUN_RLS_TESTS:
return
selected = []
deselected = []
for item in items:
item_path = getattr(item, "path", None) or getattr(item, "fspath", None)
if item_path and str(item_path).endswith(_RLS_ISOLATION_FILE):
deselected.append(item)
else:
selected.append(item)
if deselected:
config.hook.pytest_deselected(items=deselected)
items[:] = selected
@pytest.hookimpl(trylast=True, hookwrapper=True)
def pytest_runtest_teardown(item, nextitem):
"""Close pytest-asyncio's post-test clean loop before warnings collect it."""
yield
policy = asyncio.get_event_loop_policy()
try:
loop = policy.get_event_loop()
except RuntimeError:
return
if not loop.is_running() and not loop.is_closed():
loop.close()
policy.set_event_loop(None)
@pytest.fixture
@@ -116,6 +205,7 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
# Dispose engine first so all pooled connections are released,
# then reconnect to perform the schema teardown cleanly.
await engine.dispose()
await asyncio.sleep(0.01)
# Drop all tables after test (CASCADE for circular FKs)
teardown_engine = create_async_engine(
@@ -129,6 +219,7 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
await conn.execute(sa.text("CREATE SCHEMA public"))
finally:
await teardown_engine.dispose()
await asyncio.sleep(0.01)
@pytest.fixture

View File

@@ -74,19 +74,25 @@ def _mock_ai_provider(text: str, input_tokens: int = 100, output_tokens: int = 2
@pytest.fixture
def enable_ai():
"""Temporarily enable AI by setting a fake API key."""
original = settings.ANTHROPIC_API_KEY
original_anthropic = settings.ANTHROPIC_API_KEY
original_google = settings.GOOGLE_AI_API_KEY
settings.ANTHROPIC_API_KEY = "test-key-fake"
settings.GOOGLE_AI_API_KEY = None
yield
settings.ANTHROPIC_API_KEY = original
settings.ANTHROPIC_API_KEY = original_anthropic
settings.GOOGLE_AI_API_KEY = original_google
@pytest.fixture
def disable_ai():
"""Ensure AI is disabled."""
original = settings.ANTHROPIC_API_KEY
original_anthropic = settings.ANTHROPIC_API_KEY
original_google = settings.GOOGLE_AI_API_KEY
settings.ANTHROPIC_API_KEY = None
settings.GOOGLE_AI_API_KEY = None
yield
settings.ANTHROPIC_API_KEY = original
settings.ANTHROPIC_API_KEY = original_anthropic
settings.GOOGLE_AI_API_KEY = original_google
# ── Quota endpoint ──

View File

@@ -66,6 +66,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db
step = AISessionStep(
session_id=session.id,
account_id=session.account_id,
step_order=0,
step_type="question",
content={"text": "What's the issue?"},
@@ -119,7 +120,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_
root = await manager.create_root_branch(session.id)
step = AISessionStep(
session_id=session.id, step_order=0, step_type="question",
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
content={"text": "test"}, confidence_at_step=0.5,
)
test_db.add(step)
@@ -197,7 +198,7 @@ async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, tes
root = await manager.create_root_branch(session.id)
step = AISessionStep(
session_id=session.id, step_order=0, step_type="question",
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
content={"text": "test"}, confidence_at_step=0.5,
)
test_db.add(step)

View File

@@ -0,0 +1,121 @@
"""Unit tests for the in-memory escalation pub/sub bus."""
import asyncio
from uuid import uuid4
import pytest
from app.core.escalation_bus import EscalationBus
@pytest.mark.asyncio
async def test_publish_with_no_subscribers_returns_zero():
bus = EscalationBus()
delivered = await bus.publish(uuid4(), {"type": "handoff_created"})
assert delivered == 0
@pytest.mark.asyncio
async def test_subscribe_then_publish_delivers_event():
bus = EscalationBus()
account = uuid4()
queue = await bus.subscribe(account)
try:
delivered = await bus.publish(account, {"type": "handoff_created", "id": "x"})
assert delivered == 1
event = await asyncio.wait_for(queue.get(), timeout=1.0)
assert event == {"type": "handoff_created", "id": "x"}
finally:
await bus.unsubscribe(account, queue)
@pytest.mark.asyncio
async def test_two_subscribers_same_account_both_receive():
bus = EscalationBus()
account = uuid4()
q1 = await bus.subscribe(account)
q2 = await bus.subscribe(account)
try:
delivered = await bus.publish(account, {"type": "x"})
assert delivered == 2
e1 = await asyncio.wait_for(q1.get(), timeout=1.0)
e2 = await asyncio.wait_for(q2.get(), timeout=1.0)
assert e1 == e2 == {"type": "x"}
finally:
await bus.unsubscribe(account, q1)
await bus.unsubscribe(account, q2)
@pytest.mark.asyncio
async def test_subscriber_in_other_account_does_not_receive():
"""Cross-tenant isolation is the whole point — sanity check it directly."""
bus = EscalationBus()
account_a = uuid4()
account_b = uuid4()
q_a = await bus.subscribe(account_a)
q_b = await bus.subscribe(account_b)
try:
delivered = await bus.publish(account_a, {"type": "x"})
assert delivered == 1
e_a = await asyncio.wait_for(q_a.get(), timeout=1.0)
assert e_a == {"type": "x"}
# B's queue must remain empty.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(q_b.get(), timeout=0.1)
finally:
await bus.unsubscribe(account_a, q_a)
await bus.unsubscribe(account_b, q_b)
@pytest.mark.asyncio
async def test_publish_normalizes_string_uuid_account_id():
"""ORM-created objects can briefly carry string UUIDs in-memory."""
bus = EscalationBus()
account = uuid4()
queue = await bus.subscribe(account)
try:
delivered = await bus.publish(str(account), {"type": "x"})
assert delivered == 1
event = await asyncio.wait_for(queue.get(), timeout=1.0)
assert event == {"type": "x"}
finally:
await bus.unsubscribe(str(account), queue)
@pytest.mark.asyncio
async def test_unsubscribe_drops_subscriber_count_to_zero():
bus = EscalationBus()
account = uuid4()
q = await bus.subscribe(account)
assert bus.subscriber_count(account) == 1
await bus.unsubscribe(account, q)
assert bus.subscriber_count(account) == 0
@pytest.mark.asyncio
async def test_publish_drops_events_when_subscriber_queue_is_full():
"""A stuck subscriber must not back-pressure publishers."""
bus = EscalationBus()
account = uuid4()
queue = await bus.subscribe(account)
try:
# Stuff the queue past capacity (maxsize is 64) without consuming.
for _ in range(65):
await bus.publish(account, {"type": "x"})
# Sanity: queue holds at most maxsize.
assert queue.qsize() <= 64
# Publishes after capacity didn't raise — they were dropped silently.
finally:
await bus.unsubscribe(account, queue)
@pytest.mark.asyncio
async def test_unsubscribe_unknown_queue_is_noop():
"""Defensive: unsubscribe on an account/queue that isn't registered
should not raise — finally blocks rely on this."""
bus = EscalationBus()
account = uuid4()
fake_queue: asyncio.Queue = asyncio.Queue()
# Should not raise.
await bus.unsubscribe(account, fake_queue)

View File

@@ -193,6 +193,95 @@ async def test_applied_at_auto_stamped_on_first_outcome(
assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_pending_requires_notes(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_pending requires notes (the "what are you waiting on?" reason)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending"},
)
assert r.status_code == 400
assert "notes" in r.text.lower()
@pytest.mark.asyncio
async def test_pending_stores_reason_and_stamps_applied_at(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_pending stores notes under pending_reason and stamps applied_at
but NOT verified_at — the fix is parked, not verified."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending", "notes": "client power-cycling router"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "applied_pending"
assert body["pending_reason"] == "client power-cycling router"
assert body["applied_at"] is not None
assert body["verified_at"] is None
assert body["partial_notes"] is None
assert body["failure_reason"] is None
@pytest.mark.asyncio
async def test_pending_to_success_allowed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""pending is non-terminal — engineer can advance to success once verified."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
)
assert r1.status_code == 200
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 200
body = r2.json()
assert body["status"] == "applied_success"
assert body["verified_at"] is not None
# pending_reason is preserved as audit trail
assert body["pending_reason"] == "waiting on AD replication"
@pytest.mark.asyncio
async def test_pending_reason_can_be_updated(
client: AsyncClient, test_user, auth_headers, test_db
):
"""pending→pending with new notes updates the stored pending_reason."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
headers=auth_headers,
)
assert r1.status_code == 200
assert r1.json()["pending_reason"] == "waiting on AD replication"
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_pending", "notes": "now waiting on client to confirm login"},
headers=auth_headers,
)
assert r2.status_code == 200
assert r2.json()["pending_reason"] == "now waiting on client to confirm login"
@pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason(
client: AsyncClient, test_user, auth_headers, test_db

View File

@@ -0,0 +1,363 @@
"""Tests for GET /analytics/flowpilot/escalations — Escalation Mode wedge metric.
Covers the in-product time-to-first-action measurement that powers the queue
stat-card and the analytics page. The savings claim itself comes from the
manual baseline (the Assignment); these tests only cover what the in-product
endpoint returns.
"""
from datetime import datetime, timedelta, timezone
from uuid import UUID as PyUUID
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.models.session_handoff import SessionHandoff
from app.models.user import User
URL = "/api/v1/analytics/flowpilot/escalations"
# ─── Helpers ──────────────────────────────────────────────────────────────────
async def _make_session(db, *, user_id, account_id) -> AISession:
s = AISession(
user_id=user_id,
account_id=account_id,
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="escalated",
confidence_tier="discovery",
conversation_messages=[],
)
db.add(s)
await db.flush()
return s
async def _make_handoff(
db,
*,
session_id,
account_id,
user_id,
claimed_at: datetime | None,
claimed_by=None,
) -> SessionHandoff:
h = SessionHandoff(
session_id=session_id,
account_id=account_id,
handed_off_by=user_id,
intent="escalate",
snapshot={"branch_map": "stub"},
priority="normal",
claimed_at=claimed_at,
claimed_by=claimed_by,
)
db.add(h)
await db.flush()
return h
async def _make_step(db, *, session_id, account_id, created_at: datetime) -> AISessionStep:
"""Insert an ai_session_step row with an explicit created_at.
SQLAlchemy's default would set created_at to now(); the metric query keys
off this column so the tests need to control it directly.
"""
step = AISessionStep(
session_id=session_id,
account_id=account_id,
step_order=1,
step_type="note",
content={"text": "first action"},
confidence_at_step=0.5,
input_tokens=0,
output_tokens=0,
is_fork_point=False,
was_free_text=False,
was_skipped=False,
created_at=created_at,
)
db.add(step)
await db.flush()
return step
# ─── Tests ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_returns_zero_metrics_when_no_handoffs(
client: AsyncClient, auth_headers, test_user
):
"""Empty account → n_handoffs_claimed=0, all stats None, 200 OK."""
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["period"] == "30d"
assert body["n_handoffs_claimed"] == 0
assert body["n_handoffs_with_action"] == 0
assert body["avg_seconds_to_first_action"] is None
assert body["median_seconds_to_first_action"] is None
assert body["p95_seconds_to_first_action"] is None
# Disclaimer is part of the contract — pilots reading the API should see it.
assert "manual baseline" in body["metric_definition"].lower()
@pytest.mark.asyncio
async def test_happy_path_single_handoff_with_action(
client: AsyncClient, auth_headers, test_user, test_db
):
"""One claimed handoff + a step 90s later → avg=median=p95=90.0."""
user_id = PyUUID(test_user["user_data"]["id"])
account_id = PyUUID(test_user["user_data"]["account_id"])
claimed_at = datetime.now(timezone.utc) - timedelta(hours=2)
first_action_at = claimed_at + timedelta(seconds=90)
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
await _make_handoff(
test_db,
session_id=session.id,
account_id=account_id,
user_id=user_id,
claimed_at=claimed_at,
claimed_by=user_id,
)
await _make_step(
test_db,
session_id=session.id,
account_id=account_id,
created_at=first_action_at,
)
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["n_handoffs_claimed"] == 1
assert body["n_handoffs_with_action"] == 1
assert body["avg_seconds_to_first_action"] == 90.0
assert body["median_seconds_to_first_action"] == 90.0
assert body["p95_seconds_to_first_action"] == 90.0
@pytest.mark.asyncio
async def test_handoff_claimed_but_no_action(
client: AsyncClient, auth_headers, test_user, test_db
):
"""Claimed handoff with no post-claim step → counted in n_handoffs_claimed
but not in n_handoffs_with_action; aggregates remain None."""
user_id = PyUUID(test_user["user_data"]["id"])
account_id = PyUUID(test_user["user_data"]["account_id"])
claimed_at = datetime.now(timezone.utc) - timedelta(minutes=5)
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
await _make_handoff(
test_db,
session_id=session.id,
account_id=account_id,
user_id=user_id,
claimed_at=claimed_at,
claimed_by=user_id,
)
# Pre-claim step (created_at < claimed_at) — must NOT count.
await _make_step(
test_db,
session_id=session.id,
account_id=account_id,
created_at=claimed_at - timedelta(seconds=30),
)
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["n_handoffs_claimed"] == 1
assert body["n_handoffs_with_action"] == 0
assert body["avg_seconds_to_first_action"] is None
@pytest.mark.asyncio
async def test_unclaimed_handoffs_excluded(
client: AsyncClient, auth_headers, test_user, test_db
):
"""Handoffs with claimed_at IS NULL are excluded entirely."""
user_id = PyUUID(test_user["user_data"]["id"])
account_id = PyUUID(test_user["user_data"]["account_id"])
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
await _make_handoff(
test_db,
session_id=session.id,
account_id=account_id,
user_id=user_id,
claimed_at=None,
)
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 200
assert response.json()["n_handoffs_claimed"] == 0
@pytest.mark.asyncio
async def test_period_window_excludes_old_handoffs(
client: AsyncClient, auth_headers, test_user, test_db
):
"""A handoff claimed >7d ago must not appear in ?period=7d."""
user_id = PyUUID(test_user["user_data"]["id"])
account_id = PyUUID(test_user["user_data"]["account_id"])
old_claimed_at = datetime.now(timezone.utc) - timedelta(days=10)
session = await _make_session(test_db, user_id=user_id, account_id=account_id)
await _make_handoff(
test_db,
session_id=session.id,
account_id=account_id,
user_id=user_id,
claimed_at=old_claimed_at,
claimed_by=user_id,
)
await _make_step(
test_db,
session_id=session.id,
account_id=account_id,
created_at=old_claimed_at + timedelta(seconds=60),
)
await test_db.commit()
# 7d window: excluded
r7 = await client.get(URL, headers=auth_headers, params={"period": "7d"})
assert r7.status_code == 200
assert r7.json()["n_handoffs_claimed"] == 0
# 90d window: included
r90 = await client.get(URL, headers=auth_headers, params={"period": "90d"})
assert r90.status_code == 200
assert r90.json()["n_handoffs_claimed"] == 1
assert r90.json()["n_handoffs_with_action"] == 1
@pytest.mark.asyncio
async def test_aggregate_stats_for_multiple_handoffs(
client: AsyncClient, auth_headers, test_user, test_db
):
"""Three handoffs with deltas 30/60/180s → avg=90, median=60, p95≈180."""
user_id = PyUUID(test_user["user_data"]["id"])
account_id = PyUUID(test_user["user_data"]["account_id"])
base = datetime.now(timezone.utc) - timedelta(hours=3)
deltas = [30, 60, 180]
for i, delta in enumerate(deltas):
s = await _make_session(test_db, user_id=user_id, account_id=account_id)
claimed_at = base + timedelta(minutes=i * 10)
await _make_handoff(
test_db,
session_id=s.id,
account_id=account_id,
user_id=user_id,
claimed_at=claimed_at,
claimed_by=user_id,
)
await _make_step(
test_db,
session_id=s.id,
account_id=account_id,
created_at=claimed_at + timedelta(seconds=delta),
)
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
body = response.json()
assert response.status_code == 200
assert body["n_handoffs_claimed"] == 3
assert body["n_handoffs_with_action"] == 3
assert body["avg_seconds_to_first_action"] == 90.0
assert body["median_seconds_to_first_action"] == 60.0
assert body["p95_seconds_to_first_action"] == 180.0
@pytest.mark.asyncio
async def test_account_isolation_requesting_user_only_sees_own_account(
client: AsyncClient, auth_headers, test_user, test_db
):
"""A handoff in another account must not appear in this user's response.
Critical: the Phase 4 RLS pattern can fail silently if account_id is wrong.
This test would catch an account-scoped query that accidentally returned
cross-tenant rows.
"""
from app.models.account import Account
other_account = Account(name="Other MSP", display_code="OTHER001")
test_db.add(other_account)
await test_db.flush()
other_user = User(
email="other@example.com",
password_hash="x",
name="Other Tech",
role="engineer",
account_id=other_account.id,
account_role="owner",
)
test_db.add(other_user)
await test_db.flush()
s = await _make_session(
test_db, user_id=other_user.id, account_id=other_account.id
)
claimed_at = datetime.now(timezone.utc) - timedelta(hours=1)
await _make_handoff(
test_db,
session_id=s.id,
account_id=other_account.id,
user_id=other_user.id,
claimed_at=claimed_at,
claimed_by=other_user.id,
)
await _make_step(
test_db,
session_id=s.id,
account_id=other_account.id,
created_at=claimed_at + timedelta(seconds=45),
)
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 200
body = response.json()
# The other account's handoff must NOT leak into this account's response.
assert body["n_handoffs_claimed"] == 0
assert body["n_handoffs_with_action"] == 0
@pytest.mark.asyncio
async def test_viewer_role_is_blocked(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Downgrade the test user to 'viewer' and confirm the endpoint 403s."""
user_id = PyUUID(test_user["user_data"]["id"])
user = (
await test_db.execute(select(User).where(User.id == user_id))
).scalar_one()
user.account_role = "viewer"
await test_db.commit()
response = await client.get(URL, headers=auth_headers)
assert response.status_code == 403
assert "engineer" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_invalid_period_rejected(client: AsyncClient, auth_headers):
"""period=1d is not in {7d,30d,90d} — must 422."""
response = await client.get(URL, headers=auth_headers, params={"period": "1d"})
assert response.status_code == 422

View File

@@ -1,8 +1,32 @@
"""Integration tests for HandoffManager service."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from app.models.ai_session import AISession
from app.models.user import User
from app.services.handoff_manager import HandoffManager
@pytest.fixture(autouse=True)
def stub_ai_assessment():
"""Keep handoff tests focused on handoff behavior, not external AI calls."""
with patch.object(
HandoffManager,
"_generate_handoff_summary",
new=AsyncMock(
return_value={
"summary_prose": "Stub escalation assessment",
"what_we_know": [],
"likely_cause": "Stub",
"suggested_steps": [],
"confidence": "medium",
}
),
):
yield
@pytest.mark.asyncio
@@ -75,6 +99,56 @@ async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_head
assert session.status == "escalated"
assert session.escalation_package is not None
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
assert session.escalation_package["handoff_id"] == str(handoff.id)
@pytest.mark.asyncio
async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
client: AsyncClient, test_user, auth_headers, test_db, monkeypatch
):
"""Escalate should commit a handoff even when optional AI assessment is slow."""
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
async def slow_summary(self, session):
await asyncio.sleep(0.2)
return {"summary_prose": "too slow", "confidence": "medium"}
monkeypatch.setattr(
"app.services.handoff_manager.settings."
"ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS",
0.01,
)
with patch.object(
HandoffManager,
"_generate_handoff_summary_inner",
new=slow_summary,
):
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="Need senior help",
user_id=test_user["user_data"]["id"],
)
assert handoff.intent == "escalate"
assert handoff.ai_assessment is None
assert handoff.ai_assessment_data is None
await test_db.refresh(session)
assert session.status == "escalated"
assert session.handoff_count == 1
@pytest.mark.asyncio
@@ -108,8 +182,399 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
claiming_user_id=test_admin["user_data"]["id"],
)
assert claimed.claimed_by == test_admin["user_data"]["id"]
assert str(claimed.claimed_by) == test_admin["user_data"]["id"]
assert claimed.claimed_at is not None
await test_db.refresh(session)
assert session.status == "active"
@pytest.mark.asyncio
async def test_claim_session_conflict_raises_already_claimed(
client: AsyncClient, test_user, test_admin, auth_headers, test_db
):
"""Two seniors claiming simultaneously: the second raises the typed
HandoffAlreadyClaimedError carrying the winner's identity. Without this
guard both calls would silently overwrite claimed_by — the locked
race-condition story depends on a real conflict response."""
from app.services.handoff_manager import (
HandoffAlreadyClaimedError,
HandoffManager,
)
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
loser = User(
email="race-loser@example.com",
password_hash="x",
name="Race Loser",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add(loser)
await test_db.flush()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="Need help",
user_id=test_user["user_data"]["id"],
)
# First claim — admin wins.
await manager.claim_session(
handoff_id=handoff.id,
claiming_user_id=test_admin["user_data"]["id"],
)
# Second claim by a different user — standing in for the other senior who
# lost the race.
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
await manager.claim_session(
handoff_id=handoff.id,
claiming_user_id=loser.id,
)
err = exc_info.value
assert str(err.claimed_by_id) == test_admin["user_data"]["id"]
assert err.claimed_by_name # populated from User.name
assert err.claimed_at is not None
@pytest.mark.asyncio
async def test_claim_session_idempotent_for_same_user(
client: AsyncClient, test_user, test_admin, auth_headers, test_db
):
"""A re-claim by the user who already won is a no-op, not a conflict.
Defends against double-clicks / network retries on the loser-side toast."""
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="Need help",
user_id=test_user["user_data"]["id"],
)
first = await manager.claim_session(
handoff_id=handoff.id,
claiming_user_id=test_admin["user_data"]["id"],
)
second = await manager.claim_session(
handoff_id=handoff.id,
claiming_user_id=test_admin["user_data"]["id"],
)
assert str(first.claimed_by) == str(second.claimed_by) == test_admin["user_data"]["id"]
@pytest.mark.asyncio
async def test_claim_session_rejects_self_claim(
client: AsyncClient, test_user, auth_headers, test_db
):
"""The engineer who escalated a session cannot pick up their own handoff."""
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="Need help",
user_id=test_user["user_data"]["id"],
)
with pytest.raises(PermissionError):
await manager.claim_session(
handoff_id=handoff.id,
claiming_user_id=test_user["user_data"]["id"],
)
# ─── Notification dispatch ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_dispatch_emails_engineer_recipients_in_account(
client: AsyncClient, test_user, auth_headers, test_db
):
"""dispatch_escalation_notifications emails every engineer/admin in the
account except the escalator."""
# Add a second user (engineer role) in the same account.
teammate = User(
email="teammate@example.com",
password_hash="x",
name="Teammate",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add(teammate)
await test_db.flush()
# Add a viewer-role user — must NOT receive a notification.
viewer = User(
email="viewer@example.com",
password_hash="x",
name="Viewer",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="viewer",
)
test_db.add(viewer)
await test_db.flush()
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "vpn down"},
problem_summary="VPN won't connect after Win update",
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="Stuck on auth handshake",
user_id=test_user["user_data"]["id"],
)
await test_db.commit()
with patch(
"app.services.handoff_manager.EmailService.send_notification_email",
new=AsyncMock(return_value=True),
) as send:
sent = await manager.dispatch_escalation_notifications(handoff)
assert sent == 1 # only the engineer-role teammate
recipients = {call.kwargs["to_email"] for call in send.call_args_list}
assert recipients == {"teammate@example.com"}
assert viewer.email not in recipients
assert test_user["email"] not in recipients # not self-notified
title = send.call_args_list[0].kwargs["title"]
assert "VPN won't connect after Win update" in title
@pytest.mark.asyncio
async def test_dispatch_skipped_for_park_intent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""park-intent handoffs are private (waiting for client logs etc) — no
team-wide email."""
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "x"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="park",
engineer_notes="waiting on customer",
user_id=test_user["user_data"]["id"],
)
await test_db.commit()
with patch(
"app.services.handoff_manager.EmailService.send_notification_email",
new=AsyncMock(return_value=True),
) as send:
sent = await manager.dispatch_escalation_notifications(handoff)
assert sent == 0
assert send.call_count == 0
@pytest.mark.asyncio
async def test_dispatch_graceful_degradation_when_email_raises(
client: AsyncClient, test_user, auth_headers, test_db
):
"""If the email service raises (auth misconfig, network, etc.), dispatch
must NOT raise. Handoff creation has already committed; emailing is
best-effort. Codex-flagged regression."""
teammate = User(
email="t@example.com",
password_hash="x",
name="T",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add(teammate)
await test_db.flush()
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "x"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="help",
user_id=test_user["user_data"]["id"],
)
await test_db.commit()
with patch(
"app.services.handoff_manager.EmailService.send_notification_email",
new=AsyncMock(side_effect=RuntimeError("SMTP down")),
):
# Must not raise.
sent = await manager.dispatch_escalation_notifications(handoff)
assert sent == 0
@pytest.mark.asyncio
async def test_dispatch_publishes_to_escalation_bus(
client: AsyncClient, test_user, auth_headers, test_db
):
"""dispatch_escalation_notifications puts an event on the in-memory bus
so connected SSE subscribers see live arrivals."""
from app.core.escalation_bus import bus as escalation_bus
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "x"},
problem_summary="VPN down",
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
manager = HandoffManager(test_db)
handoff = await manager.create_handoff(
session_id=session.id,
intent="escalate",
engineer_notes="please help",
user_id=test_user["user_data"]["id"],
)
await test_db.commit()
from uuid import UUID as PyUUID
account_id = PyUUID(test_user["user_data"]["account_id"])
queue = await escalation_bus.subscribe(account_id)
try:
with patch(
"app.services.handoff_manager.EmailService.send_notification_email",
new=AsyncMock(return_value=True),
):
await manager.dispatch_escalation_notifications(handoff)
import asyncio
event = await asyncio.wait_for(queue.get(), timeout=1.0)
assert event["type"] == "handoff_created"
assert event["handoff_id"] == str(handoff.id)
assert event["session_id"] == str(session.id)
assert event["priority"] == "normal"
finally:
await escalation_bus.unsubscribe(account_id, queue)
@pytest.mark.asyncio
async def test_create_handoff_endpoint_dispatches_on_escalate(
client: AsyncClient, test_user, auth_headers, test_db
):
"""End-to-end: POST /handoff with intent=escalate triggers
dispatch_escalation_notifications after commit. Verifies the wiring in
the endpoint, not just the manager method."""
teammate = User(
email="t2@example.com",
password_hash="x",
name="T2",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add(teammate)
await test_db.commit()
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "x"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
with patch(
"app.services.handoff_manager.EmailService.send_notification_email",
new=AsyncMock(return_value=True),
) as send:
resp = await client.post(
f"/api/v1/ai-sessions/{session.id}/handoff",
headers=auth_headers,
json={"intent": "escalate", "engineer_notes": "Need help"},
)
assert resp.status_code == 201
assert send.call_count == 1
assert send.call_args.kwargs["to_email"] == "t2@example.com"

View File

@@ -0,0 +1,55 @@
# backend/tests/test_psa_tickets.py
"""Routing and auth tests for new ticket management endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_ticket_requires_auth(client):
"""POST /tickets returns 401 without auth."""
response = await client.post(
"/api/v1/integrations/psa/tickets",
json={
"summary": "Test", "company_id": 1, "board_id": 1,
"status_id": 1, "priority_id": 1
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_resources_requires_auth(client):
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
"""search endpoint returns TicketListResponse shape when no PSA connected."""
response = await client.get(
"/api/v1/integrations/psa/tickets/search",
headers=auth_headers,
)
# No PSA connection → 400 or 502; with PSA → 200
assert response.status_code in (200, 400, 502)
if response.status_code == 200:
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
@pytest.mark.asyncio
async def test_update_status_requires_auth(client):
response = await client.patch(
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_parse_requires_auth(client):
response = await client.post(
"/api/v1/integrations/psa/tickets/ai-parse",
json={"prompt": "New ticket for Acme"},
)
assert response.status_code == 401

View File

@@ -50,6 +50,7 @@ async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession:
conn = PsaConnection(
account_id=user["user_data"]["account_id"],
provider="connectwise",
display_name="Test ConnectWise",
site_url="https://fake.cw.local",
company_id="TEST",
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),

View File

@@ -11,30 +11,57 @@ Tests bypass FastAPI entirely — raw asyncpg connections only.
MUST FAIL before Task 10 (RLS migration) and PASS after it.
Run with:
DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
RUN_RLS_TESTS=1 DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
The test DB is patherly_test (matches conftest.py default).
The test DB comes from DATABASE_TEST_URL, matching conftest.py.
"""
import os
import subprocess
import sys
import uuid
from pathlib import Path
from urllib.parse import unquote, urlsplit
import asyncpg
import pytest
import pytest_asyncio
# All tests in this module use module-scoped async fixtures (admin_conn,
# seed_rls_test_data) which run on the module event loop. Without this marker,
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
# "Future attached to a different loop" errors on the asyncpg connections.
pytestmark = pytest.mark.asyncio(loop_scope="module")
pytestmark = [
pytest.mark.asyncio(loop_scope="module"),
pytest.mark.rls,
]
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
_DATABASE_TEST_URL = os.getenv(
"DATABASE_TEST_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
)
_DATABASE_TEST_URL_ASYNCPG = _DATABASE_TEST_URL.replace(
"postgresql+asyncpg://",
"postgresql://",
1,
)
_DATABASE_TEST_URL_SYNC = _DATABASE_TEST_URL_ASYNCPG
_TEST_DB_PARTS = urlsplit(_DATABASE_TEST_URL_ASYNCPG)
_DB_HOST = os.getenv("TEST_DB_HOST", _TEST_DB_PARTS.hostname or "localhost")
_DB_PORT = int(os.getenv("TEST_DB_PORT", str(_TEST_DB_PARTS.port or 5432)))
_DB_NAME = os.getenv(
"TEST_DB_NAME",
unquote(_TEST_DB_PARTS.path.lstrip("/") or "resolutionflow_test"),
)
_ADMIN_USER = os.getenv(
"TEST_DB_ADMIN_USER",
unquote(_TEST_DB_PARTS.username or "postgres"),
)
_ADMIN_PASSWORD = os.getenv(
"TEST_DB_ADMIN_PASSWORD",
unquote(_TEST_DB_PARTS.password or "postgres"),
)
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
_ADMIN_DSN = f"postgresql://postgres:postgres@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
PLATFORM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
@@ -55,23 +82,33 @@ def _ensure_rls_schema():
the full migration-managed schema (including RLS policies) is in place.
"""
backend_dir = Path(__file__).parent.parent
env = os.environ.copy()
env["DATABASE_URL"] = _DATABASE_TEST_URL
env["DATABASE_URL_SYNC"] = _DATABASE_TEST_URL_SYNC
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=backend_dir,
env=env,
check=True,
capture_output=True,
)
@pytest.fixture(scope="module")
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def admin_conn(_ensure_rls_schema):
"""Superuser asyncpg connection for fixture setup and teardown."""
conn = await asyncpg.connect(_ADMIN_DSN)
conn = await asyncpg.connect(
host=_DB_HOST,
port=_DB_PORT,
database=_DB_NAME,
user=_ADMIN_USER,
password=_ADMIN_PASSWORD,
)
yield conn
await conn.close()
@pytest.fixture(scope="module", autouse=True)
@pytest_asyncio.fixture(scope="module", loop_scope="module", autouse=True)
async def seed_rls_test_data(admin_conn):
"""
Create two isolated test accounts, one user per account, and one private
@@ -154,7 +191,7 @@ async def seed_rls_test_data(admin_conn):
await admin_conn.execute("DELETE FROM tree_tags WHERE slug = 'rls-global-tag'")
@pytest.fixture
@pytest_asyncio.fixture(loop_scope="module")
async def conn_a():
"""App-role connection, tenant context = Account A."""
conn = await asyncpg.connect(
@@ -168,7 +205,7 @@ async def conn_a():
await conn.close()
@pytest.fixture
@pytest_asyncio.fixture(loop_scope="module")
async def conn_b():
"""App-role connection, tenant context = Account B."""
conn = await asyncpg.connect(
@@ -182,7 +219,7 @@ async def conn_b():
await conn.close()
@pytest.fixture
@pytest_asyncio.fixture(loop_scope="module")
async def conn_no_context():
"""App-role connection with NO tenant context set."""
conn = await asyncpg.connect(
@@ -288,7 +325,7 @@ async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
# Phase 2 fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def session_row_ids(admin_conn):
"""
Insert one `sessions` row and one `ai_sessions` row for each of
@@ -644,13 +681,15 @@ async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_i
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
"""Private/non-public steps owned by Account B must not be visible to Account A."""
user_b_id = await _get_user_b_id(admin_conn)
private_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
id, account_id, created_by, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{private_step_id}', '{ACCOUNT_B_ID}', 'RLS Private Step', 'action',
'{private_step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'RLS Private Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
@@ -668,13 +707,15 @@ async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_c
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
user_b_id = await _get_user_b_id(admin_conn)
public_step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
id, account_id, created_by, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{public_step_id}', '{ACCOUNT_B_ID}', 'RLS Public Step', 'action',
'{public_step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'RLS Public Step', 'action',
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
)
""")
@@ -728,10 +769,11 @@ async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a):
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
id, account_id, created_by, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 RLS Step', 'action',
'{step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'Phase3 RLS Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
@@ -768,10 +810,11 @@ async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a)
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO step_library (
id, account_id, title, step_type, content,
id, account_id, created_by, title, step_type, content,
visibility, is_active, created_at, updated_at
) VALUES (
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 Usage Step', 'action',
'{step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
'Phase3 Usage Step', 'action',
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
)
""")
@@ -971,10 +1014,10 @@ async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn
session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO script_builder_sessions (
id, user_id, account_id, language, created_at, updated_at
id, user_id, account_id, language, origin, created_at, updated_at
) VALUES (
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'powershell', NOW(), NOW()
'powershell', 'standalone', NOW(), NOW()
)
""")
try:
@@ -1001,22 +1044,24 @@ async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_
ai_session_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_sessions (
id, user_id, account_id, flow_type, status, confidence_tier,
id, user_id, account_id, session_type, intake_type,
intake_content, status, confidence_tier, confidence_score,
created_at, updated_at
) VALUES (
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'troubleshooting', 'active', 'guided', NOW(), NOW()
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
NOW(), NOW()
)
""")
step_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO ai_session_steps (
id, session_id, account_id, step_type, content,
id, session_id, account_id, step_order, step_type, content,
created_at
) VALUES (
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
'question', 'Phase4 RLS test step', NOW()
1, 'question', '{{"text": "Phase4 RLS test step"}}'::jsonb, NOW()
)
""")
try:
@@ -1040,11 +1085,11 @@ async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
notif_id = str(uuid.uuid4())
await admin_conn.execute(f"""
INSERT INTO notifications (
id, user_id, account_id, type, title, message,
id, user_id, account_id, event, title, body,
is_read, created_at
) VALUES (
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
'info', 'Phase4 RLS Test', 'RLS isolation test notification',
'test_event', 'Phase4 RLS Test', 'RLS isolation test notification',
FALSE, NOW()
)
""")
@@ -1055,4 +1100,3 @@ async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
assert len(rows) == 0, "Account A should not see Account B notifications"
finally:
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")

View File

@@ -472,19 +472,20 @@ class TestScriptBuilderSlugCollision:
# Pre-create a template with slug "test-script" to cause collision
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
account_id = user_resp.json()["account_id"]
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, name, slug, script_body,
(id, category_id, created_by, account_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid,
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid, :account_id,
'Test Script', 'test-script', 'echo hello',
'{"parameters": []}', '{}', '{}', '["powershell"]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "uid": user_id},
{"id": str(uuid_mod.uuid4()), "uid": user_id, "account_id": account_id},
)
await test_db.commit()
@@ -561,6 +562,7 @@ class TestScriptTemplateFilters:
"""mine=true returns only templates created by the current user."""
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
account_id = user_resp.json()["account_id"]
second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
second_user_id = second_resp.json()["id"]
@@ -571,32 +573,32 @@ class TestScriptTemplateFilters:
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
(:id, :cat, :uid, :account_id, NULL,
'My Script', 'my-script', 'echo mine',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id},
)
# Create template owned by second user (no team_id, so visible to all)
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
(:id, :cat, :uid, :account_id, NULL,
'Other Script', 'other-script', 'echo other',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id},
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id, "account_id": account_id},
)
await test_db.commit()
@@ -617,6 +619,7 @@ class TestScriptTemplateFilters:
"""shared=true returns only templates shared with the user's team."""
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
account_id = user_resp.json()["account_id"]
cat_id = "b0000000-0000-0000-0000-000000000001"
@@ -639,32 +642,32 @@ class TestScriptTemplateFilters:
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, :tid,
(:id, :cat, :uid, :account_id, :tid,
'Team Script', 'team-script', 'echo team',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id},
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id, "tid": team_id},
)
# Template NOT shared (no team_id)
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
(:id, :cat, :uid, :account_id, NULL,
'Personal Script', 'personal-script', 'echo personal',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id},
)
await test_db.commit()

View File

@@ -49,7 +49,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db
await test_db.flush()
step = AISessionStep(
session_id=session.id, step_order=0, step_type="question",
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
content={"text": "test"}, confidence_at_step=0.5,
)
test_db.add(step)
@@ -88,7 +88,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_
await test_db.flush()
step = AISessionStep(
session_id=session.id, step_order=0, step_type="question",
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
content={"text": "test"}, confidence_at_step=0.5,
)
test_db.add(step)

View File

@@ -1,8 +1,41 @@
"""API endpoint tests for session handoffs."""
from unittest.mock import AsyncMock, patch
from uuid import UUID as PyUUID
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.endpoints.session_handoffs import stream_escalations
from app.core.escalation_bus import bus as escalation_bus
from app.models.ai_session import AISession
from app.models.session_handoff import SessionHandoff
from app.models.user import User
from app.services.handoff_manager import HandoffManager
class _ConnectedRequest:
async def is_disconnected(self) -> bool:
return False
@pytest.fixture(autouse=True)
def stub_ai_assessment():
"""Endpoint tests should not wait on the external AI assessment path."""
with patch.object(
HandoffManager,
"_generate_handoff_summary",
new=AsyncMock(
return_value={
"summary_prose": "Stub escalation assessment",
"what_we_know": [],
"likely_cause": "Stub",
"suggested_steps": [],
"confidence": "medium",
}
),
):
yield
@pytest.mark.asyncio
@@ -58,3 +91,234 @@ async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db):
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_claim_blocked_for_viewer_role(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /handoffs/{id}/claim must 403 for viewer-role users.
Codex review flagged the missing role gate as wedge-relevant: the
race-condition story (two seniors clicking Pick Up simultaneously)
requires auth gating for audit integrity. Viewers must not be able
to claim escalations.
"""
# Create a session + handoff as the engineer-role test_user (default = owner).
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.commit()
create_resp = await client.post(
f"/api/v1/ai-sessions/{session.id}/handoff",
headers=auth_headers,
json={"intent": "escalate", "engineer_notes": "Need help"},
)
assert create_resp.status_code == 201
handoff_id = create_resp.json()["id"]
# Downgrade the user to viewer.
user_id = PyUUID(test_user["user_data"]["id"])
user = (
await test_db.execute(select(User).where(User.id == user_id))
).scalar_one()
user.account_role = "viewer"
await test_db.commit()
claim_resp = await client.post(
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
headers=auth_headers,
)
assert claim_resp.status_code == 403
assert "engineer" in claim_resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_escalations_stream_blocked_for_viewer(
client: AsyncClient, test_user, auth_headers, test_db
):
"""SSE stream is role-gated to engineer-or-admin (matches queue/claim)."""
user_id = PyUUID(test_user["user_data"]["id"])
user = (
await test_db.execute(select(User).where(User.id == user_id))
).scalar_one()
user.account_role = "viewer"
await test_db.commit()
resp = await client.get(
"/api/v1/ai-sessions/escalations/stream", headers=auth_headers
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_escalations_stream_returns_sse_content_type(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Engineer/owner can open the SSE stream and gets text/event-stream
plus an initial `ready` event. Read just enough bytes to confirm the
handshake — the full pub/sub flow is covered by the bus + dispatcher tests
separately.
Do not use `client.stream()` here: HTTPX's ASGITransport buffers the whole
response body before returning, which hangs forever for an infinite SSE
stream.
"""
user_id = PyUUID(test_user["user_data"]["id"])
user = (
await test_db.execute(select(User).where(User.id == user_id))
).scalar_one()
resp = await stream_escalations(_ConnectedRequest(), current_user=user)
assert resp.media_type == "text/event-stream"
body_iterator = resp.body_iterator
try:
first = await anext(body_iterator)
finally:
await body_iterator.aclose()
assert "event: ready" in first
assert '"account_id"' in first
assert escalation_bus.subscriber_count(user.account_id) == 0
@pytest.mark.asyncio
async def test_claim_allowed_for_engineer_role(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
original_engineer = User(
email="original-engineer@example.com",
password_hash="x",
name="Original Engineer",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add(original_engineer)
await test_db.flush()
session = AISession(
user_id=original_engineer.id,
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
handoff = SessionHandoff(
session_id=session.id,
account_id=test_user["user_data"]["account_id"],
handed_off_by=original_engineer.id,
intent="escalate",
snapshot={"problem_summary": "test"},
engineer_notes="Need help",
)
test_db.add(handoff)
await test_db.commit()
# Default test_user role is "owner", which passes engineer-or-admin.
claim_resp = await client.post(
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
headers=auth_headers,
)
assert claim_resp.status_code == 200
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
assert claim_resp.json()["claimed_at"] is not None
@pytest.mark.asyncio
async def test_claim_rejects_self_claim(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /handoffs/{id}/claim returns 403 for the original escalator."""
session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="guided",
intake_type="free_text",
intake_content={"text": "test"},
status="escalated",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
handoff = SessionHandoff(
session_id=session.id,
account_id=test_user["user_data"]["account_id"],
handed_off_by=test_user["user_data"]["id"],
intent="escalate",
snapshot={"problem_summary": "test"},
engineer_notes="Need help",
)
test_db.add(handoff)
await test_db.commit()
claim_resp = await client.post(
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
headers=auth_headers,
)
assert claim_resp.status_code == 403
assert "own handoff" in claim_resp.json()["detail"]
@pytest.mark.asyncio
async def test_escalation_queue_excludes_own_escalations(
client: AsyncClient, test_user, auth_headers, test_db
):
"""The post-escalation dashboard queue should not show your own handoff."""
own_session = AISession(
user_id=test_user["user_data"]["id"],
account_id=test_user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "own"},
status="escalated",
confidence_tier="discovery",
conversation_messages=[],
)
other_engineer = User(
email="other-engineer@example.com",
password_hash="x",
name="Other Engineer",
role="engineer",
account_id=test_user["user_data"]["account_id"],
account_role="engineer",
)
test_db.add_all([own_session, other_engineer])
await test_db.flush()
other_session = AISession(
user_id=other_engineer.id,
account_id=test_user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "other"},
status="escalated",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(other_session)
await test_db.commit()
resp = await client.get("/api/v1/ai-sessions/escalation-queue", headers=auth_headers)
assert resp.status_code == 200
ids = {item["id"] for item in resp.json()}
assert str(own_session.id) not in ids
assert str(other_session.id) in ids

View File

@@ -45,6 +45,7 @@ async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, tes
output = SessionResolutionOutput(
session_id=session.id,
account_id=session.account_id,
output_type="psa_ticket_notes",
generated_content="Original",
status="draft",

View File

@@ -219,7 +219,7 @@ class TestSessionSharing:
json={"visibility": "public"},
headers=other_headers
)
assert response.status_code == 403
assert response.status_code == 404
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
"""Creating a share for nonexistent session returns 404."""

View File

@@ -213,15 +213,28 @@ async def test_record_decision_persists_and_bumps_state_version(
title="x",
description="y",
confidence_pct=50,
ai_drafted_script="Write-Output 'ok'",
)
test_db.add(fix)
await test_db.commit()
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "draft_template"},
)
# The draft_template path calls TemplateExtractionService, which needs an
# AI provider configured. CI doesn't set ANTHROPIC_API_KEY/GOOGLE_AI_API_KEY,
# and this test isn't exercising the AI integration — patch the extractor
# with a minimal valid response so the rest of the decision flow runs.
extractor_stub = AsyncMock(return_value={
"templated_body": "Write-Output 'ok'",
"parameters": [],
})
with patch(
"app.api.endpoints.session_suggested_fixes._extract_template_parameters",
extractor_stub,
):
r = await client.post(
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
headers=auth_headers,
json={"decision": "draft_template"},
)
assert r.status_code == 200
assert r.json()["user_decision"] == "draft_template"

View File

@@ -43,7 +43,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str):
async def _login(client: AsyncClient, email: str, password: str) -> dict:
"""Log in and return Authorization headers."""
resp = await client.post(
"/api/v1/auth/login",
"/api/v1/auth/login/json",
json={"email": email, "password": password},
)
assert resp.status_code == 200, f"Login failed: {resp.text}"
@@ -101,11 +101,11 @@ async def test_category_tree_count_scoped_to_account(
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a")
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b")
# Shared category (account_id=None means global)
# Categories are tenant-scoped; the endpoint must only count account A's trees.
category = TreeCategory(
name="Shared Category",
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
account_id=None,
account_id=acct_a.id,
is_active=True,
)
test_db.add(category)
@@ -270,6 +270,7 @@ async def test_get_session_returns_404_not_403_for_other_user(
session_b = Session(
tree_id=tree_b.id,
user_id=user_b.id,
account_id=acct_b.id,
tree_snapshot={"id": "root", "type": "start", "children": []},
path_taken=[],
decisions=[],
@@ -384,6 +385,7 @@ async def test_share_revoke_returns_404_not_403_for_other_user(
session_b = Session(
tree_id=tree_b.id,
user_id=user_b.id,
account_id=acct_b.id,
tree_snapshot={"id": "root", "type": "start", "children": []},
path_taken=[],
decisions=[],
@@ -534,6 +536,7 @@ async def test_maintenance_schedule_returns_404_for_other_team(
# Create a schedule for that tree
schedule_b = MaintenanceSchedule(
tree_id=tree_b.id,
account_id=acct_b.id,
created_by=user_b.id,
cron_expression="0 2 * * 0",
timezone="UTC",

View File

@@ -4,6 +4,7 @@ from datetime import datetime, timezone, timedelta
from httpx import AsyncClient
from uuid import uuid4
from app.models.account import Account
from app.models.tree import Tree
from app.models.tree_share import TreeShare
from app.models.user import User
@@ -287,13 +288,17 @@ class TestTreeSharing:
@pytest.mark.asyncio
async def test_migration_defaults_visibility_to_team(test_db):
"""Test that existing trees default to 'team' visibility after migration."""
account = Account(name="Migration Default Test", display_code=uuid4().hex[:8])
test_db.add(account)
await test_db.flush()
# Create a tree without specifying visibility
tree = Tree(
name="Old Tree",
description="Created before migration",
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []},
author_id=None,
account_id=None
account_id=account.id
)
test_db.add(tree)
await test_db.commit()

View File

@@ -359,7 +359,7 @@ async def test_delete_upload_forbidden_for_non_owner(client, auth_headers, test_
f"/api/v1/uploads/{upload.id}", headers=other_headers
)
assert response.status_code == 403
assert response.status_code == 404
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,87 @@
# Phase 9 Review Issues
Date: 2026-04-24
Scope reviewed:
- `backend/app/api/endpoints/script_builder.py`
- `backend/app/api/endpoints/session_suggested_fixes.py`
- `backend/app/services/script_builder_service.py`
- `frontend/src/pages/AssistantChatPage.tsx`
- `frontend/src/components/pilot/ScriptBuilderTab.tsx`
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
## 1. "Applied partially" from the escalation intercept cannot persist
Severity: High
The escalation intercept offers an "applied partially" choice, but the frontend
sends `applied_partial` without notes. The backend requires notes for that
outcome and returns 400. The frontend catches the error silently and still opens
the conclude modal, so the user can believe the partial outcome was recorded
when it was not.
Relevant files:
- `frontend/src/pages/AssistantChatPage.tsx:659`
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx:56`
- `backend/app/api/endpoints/session_suggested_fixes.py:316`
Why this matters:
- `handleInterceptChoice()` maps the partial button directly to
`patchOutcome(..., "applied_partial")`.
- The call does not provide `notes`.
- `PATCH /suggested-fixes/{fix_id}/outcome` rejects `applied_partial` without
notes.
- The catch block is silent and the UI continues into the conclude flow.
- The recorded fix status therefore remains unchanged while the user sees a
flow that implies the partial outcome was accepted.
Recommended fix:
- Prompt for partial notes before calling `patchOutcome()` with
`applied_partial`.
- Do not proceed to the conclude modal if the partial outcome write fails.
- Consider hiding or disabling the partial option when it is not applicable, or
pass the current fix status into `EscalateInterceptDialog` so it can render
valid choices only.
- Add a regression test covering the partial escalation-intercept path.
## 2. Script Builder can attach stale script state to a newer active fix
Severity: Medium/High
`ScriptBuilderTab` keeps local builder state across active-fix changes within
the same pilot chat. If a new active fix supersedes the previous one while the
tab remains mounted, old messages, `latestScript`, or editor text can remain in
memory while submission uses the new `fix.id`.
Relevant files:
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:55`
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:78`
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:150`
- `frontend/src/pages/AssistantChatPage.tsx:399`
- `frontend/src/pages/AssistantChatPage.tsx:1630`
Why this matters:
- `ScriptBuilderTab` initializes `editorBuffer`, messages, and latest script
from props and builder-session data.
- The create/resume effect depends on `pilotSessionId`, not `fix.id`.
- `AssistantChatPage` detects active-fix changes but only closes the script
panel.
- The rendered `ScriptBuilderTab` is not keyed by active fix id.
- Submitting a stale builder draft calls the script patch endpoint with the
current `fix.id`, so an older script can be attached to a newer fix.
Recommended fix:
- Reset Script Builder local state when `activeFix.id` changes.
- Key the rendered `ScriptBuilderTab` by `activeFix.id` if the intended UX is a
fresh builder surface per fix.
- If inline builder conversations are intended to resume per fix, extend the
backend idempotency model to include the fix id instead of only
`(user_id, ai_session_id)`.
- Add a frontend regression test for an active fix changing while the Script
Builder tab is mounted.
## Review Context
This review was based on code inspection of the latest committed Phase 9
implementation. No tracked working-tree diffs were present at review time.

View File

@@ -1,4 +1,4 @@
# Lessons Archive (1-40)
# Lessons Archive (1-70)
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
@@ -81,3 +81,67 @@
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
---
## Archived Lessons (41-70)
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
**57. Node field priority:** `title``question``description``content``label`. See `copilot_service.py`.
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.

View File

@@ -0,0 +1,63 @@
# ConnectWise integration docs
Reference material for ResolutionFlow's ConnectWise Manage integration.
This folder pairs a **human-editable source** (the XLSX) with two
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
read the Markdown; edits happen in the XLSX.
## Files
| File | Role | Edit? |
|------|------|-------|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
## Regeneration workflow
After editing the XLSX:
```bash
cd docs/integrations/connectwise/source
pip install -r requirements.txt
python generate_role_docs.py \
--source Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
```
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
is what reviewers should scrutinize — it is the source of truth for code.
## Querying the YAML from integration code
The YAML groups permissions by module and action. Example — checking
what `Inquire: ALL` means for Service Desk → Service Tickets:
```python
import yaml
from pathlib import Path
doc = yaml.safe_load(
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
)
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
print(levels["ALL"])
```
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
layer should use when the required permission level for a given API
endpoint needs to be documented or validated against an assigned role.
## Conventions
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
- **`Not applicable` notes** in a verb's cell mean the meaningful level
is documented under another verb (almost always `inquire`) — the
generator preserves these as `note:` fields rather than inventing
placeholder levels.
- **The XLSX is the single source of input.** Never hand-edit the YAML
or Markdown; your changes will be overwritten on the next regeneration.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
"""
Generate ConnectWise security-role documentation from the source XLSX.
Produces:
- api-member-security-roles.yaml : machine-readable source of truth
- api-member-security-roles.md : human-readable reference
Re-run this script after editing the source XLSX. Both outputs are
deterministic — they will produce identical content from identical input,
so diffs in version control reflect only real permission-model changes.
Usage:
python generate_role_docs.py \
--source source/Security_Roles_Matrix_11132017.xlsx \
--out-yaml ../api-member-security-roles.yaml \
--out-md ../api-member-security-roles.md
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from openpyxl import load_workbook
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
# A level description line looks like "ALL: text..." or "NONE: text..."
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
# Recognized ConnectWise permission levels, most-to-least privileged.
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
VERBS = ["add", "edit", "delete", "inquire"]
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
@dataclass
class CellPermission:
"""Parsed contents of a single (action, verb) cell."""
levels: Dict[str, str] = field(default_factory=dict) # level -> description
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
raw: str = "" # original cell text, preserved for audit
@dataclass
class ActionRow:
module: str
action: str
permissions: Dict[str, CellPermission] # verb -> CellPermission
def parse_cell(raw: Optional[str]) -> CellPermission:
"""Parse a single cell's multi-line content into levels + note."""
if raw is None:
return CellPermission(raw="")
text = str(raw).strip()
cp = CellPermission(raw=text)
if not text:
return cp
# Split into candidate entries. Each entry is typically one line that
# starts with a level prefix, but description text can itself contain
# newlines. We therefore split on newlines and accumulate continuation
# lines into the preceding entry.
current_level: Optional[str] = None
current_buf: List[str] = []
note_buf: List[str] = []
def flush_level() -> None:
nonlocal current_level, current_buf
if current_level is not None:
cp.levels[current_level] = " ".join(current_buf).strip()
current_level = None
current_buf = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
m = LEVEL_LINE.match(line)
if m:
flush_level()
current_level = m.group(1).upper()
current_buf = [m.group(2).strip()]
elif current_level is not None:
current_buf.append(line)
else:
# No level prefix yet — belongs to the note.
note_buf.append(line)
flush_level()
if note_buf:
cp.note = " ".join(note_buf).strip()
return cp
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
wb = load_workbook(xlsx_path, data_only=True)
ws = wb.active # Single sheet in this workbook.
# Header row is row 2 per the source file; data begins row 3.
actions: List[ActionRow] = []
for r in range(3, ws.max_row + 1):
module = ws.cell(row=r, column=1).value
action = ws.cell(row=r, column=2).value
if not (module or action):
continue # skip fully empty rows
if not module or not action:
# Partial row — keep but flag. This shouldn't happen in the
# current source; if it does, the generator should fail loudly
# rather than silently produce wrong output.
raise ValueError(
f"Row {r} has a missing Module or Action: "
f"module={module!r}, action={action!r}"
)
perms: Dict[str, CellPermission] = {}
for verb, col in VERB_COLS.items():
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
actions.append(
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
)
return actions
# ---------------------------------------------------------------------------
# Output: YAML
# ---------------------------------------------------------------------------
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
"""Build a plain-dict representation that YAML dumps cleanly."""
# Group by module, preserving action order within each module.
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
doc = {
"metadata": {
"source_file": source_file,
"generated_on": date.today().isoformat(),
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
"description": (
"ConnectWise security-role matrix. Each (module, action) entry "
"describes what each access level (ALL, MY, MINE, NONE) means "
"for the Add, Edit, Delete, and Inquire verbs. This is a "
"reference catalog, not a per-role assignment — role "
"assignments live in ConnectWise and are mirrored in the "
"ResolutionFlow integration config."
),
"level_order_most_to_least_privileged": LEVEL_ORDER,
},
"modules": {},
}
for module_name, rows in modules.items():
module_block = {"actions": {}}
for a in rows:
action_block: Dict[str, object] = {}
for verb in VERBS:
cell = a.permissions[verb]
entry: Dict[str, object] = {}
if cell.levels:
# Emit levels in canonical order, only those present.
entry["levels"] = {
lvl: cell.levels[lvl]
for lvl in LEVEL_ORDER
if lvl in cell.levels
}
if cell.note:
entry["note"] = cell.note
if not entry:
# Truly empty cell — represent explicitly so downstream
# consumers can distinguish "empty" from "missing".
entry["note"] = "(no description provided)"
action_block[verb] = entry
module_block["actions"][a.action] = action_block
doc["modules"][module_name] = module_block
return doc
class _LiteralStr(str):
"""Marker type so PyYAML renders long strings as block literals."""
def _literal_presenter(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.add_representer(_LiteralStr, _literal_presenter)
def _use_block_style_for_long_strings(obj):
"""Recursively wrap long strings so the YAML is readable, not one-line."""
if isinstance(obj, dict):
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_use_block_style_for_long_strings(v) for v in obj]
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
return _LiteralStr(obj)
return obj
def dump_yaml(doc: dict, out_path: Path) -> None:
prepared = _use_block_style_for_long_strings(doc)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as f:
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
f.write("# Generated from the source XLSX; do not edit by hand.\n")
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
yaml.dump(
prepared,
f,
sort_keys=False,
allow_unicode=True,
width=100,
default_flow_style=False,
)
# ---------------------------------------------------------------------------
# Output: Markdown
# ---------------------------------------------------------------------------
def _md_escape(text: str) -> str:
"""Escape pipes and collapse whitespace for Markdown table cells."""
return text.replace("|", "\\|").replace("\n", " ").strip()
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
modules: Dict[str, List[ActionRow]] = {}
for a in actions:
modules.setdefault(a.module, []).append(a)
lines: List[str] = []
lines.append("# ConnectWise API Member — Security Roles Reference")
lines.append("")
lines.append(
f"_Generated {date.today().isoformat()} from "
f"`{source_file}`. Do not edit by hand — update the XLSX and "
f"re-run `generate_role_docs.py`._"
)
lines.append("")
lines.append("## How to read this document")
lines.append("")
lines.append(
"Each ConnectWise module lists the actions it governs. For every "
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
"**Inquire** — can be granted at one of these levels, most to "
"least privileged:"
)
lines.append("")
lines.append("| Level | Meaning |")
lines.append("|-------|---------|")
lines.append("| `ALL` | Access to all records in the system. |")
lines.append("| `MY` | Access to records owned by the user's team. |")
lines.append("| `MINE` | Access only to records owned by the user. |")
lines.append("| `NONE` | No access. |")
lines.append("")
lines.append(
"Not every level applies to every action — the source matrix "
"only documents the levels that are meaningful for each cell. "
"Cells marked _Not applicable_ reference another verb (usually "
"Inquire) where the meaningful level is defined."
)
lines.append("")
lines.append(
"The machine-readable form of this document is "
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
"Use the YAML when writing integration code; use this Markdown "
"when reviewing, discussing, or onboarding."
)
lines.append("")
lines.append("## Table of contents")
lines.append("")
for module_name in modules:
anchor = module_name.lower().replace(" ", "-").replace("/", "")
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
lines.append("")
for module_name, rows in modules.items():
lines.append(f"## {module_name}")
lines.append("")
for a in rows:
lines.append(f"### {a.action}")
lines.append("")
lines.append("| Verb | Level | Description |")
lines.append("|------|-------|-------------|")
wrote_any = False
for verb in VERBS:
cell = a.permissions[verb]
if cell.levels:
for lvl in LEVEL_ORDER:
if lvl in cell.levels:
lines.append(
f"| {verb.capitalize()} | `{lvl}` | "
f"{_md_escape(cell.levels[lvl])} |"
)
wrote_any = True
elif cell.note:
lines.append(
f"| {verb.capitalize()} | — | "
f"_{_md_escape(cell.note)}_ |"
)
wrote_any = True
if not wrote_any:
lines.append("| — | — | _(no description provided)_ |")
lines.append("")
return "\n".join(lines) + "\n"
def write_markdown(md_text: str, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md_text, encoding="utf-8")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True,
help="Path to the source .xlsx")
parser.add_argument("--out-yaml", type=Path, required=True,
help="Path to write the YAML output")
parser.add_argument("--out-md", type=Path, required=True,
help="Path to write the Markdown output")
args = parser.parse_args()
actions = read_matrix(args.source)
doc = build_yaml_document(actions, source_file=args.source.name)
dump_yaml(doc, args.out_yaml)
md = build_markdown(actions, source_file=args.source.name)
write_markdown(md, args.out_md)
# Quick data-quality summary to stdout — helpful when re-running after edits.
from collections import Counter
modules_seen = Counter(a.module for a in actions)
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
for m, n in modules_seen.most_common():
print(f" {m}: {n}")
print(f"\nWrote {args.out_yaml}")
print(f"Wrote {args.out_md}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
# Dependencies for generate_role_docs.py.
# These are only needed when regenerating the role docs from the XLSX —
# they are not runtime dependencies of ResolutionFlow itself.
openpyxl>=3.1,<4.0
PyYAML>=6.0,<7.0

View File

@@ -0,0 +1,494 @@
# Design: ResolutionFlow GTM — Escalation-Mode-First Wedge
Generated by /office-hours on 2026-04-26
Branch: main
Repo: chihlasm/resolutionflow
Status: APPROVED
Mode: Startup
## Problem Statement
ResolutionFlow is a multi-tenant SaaS troubleshooting platform for MSPs, currently
in Go-to-Market Validation (pre-PMF). The backend is feature-complete (55+ endpoints,
100+ tests, FlowPilot telemetry baseline accruing). The product has users but no
paying customers.
The blocker is not engineering completeness. The blocker is the absence of a sharp
GTM story tied to a number a buyer can verify. The session reframed the wedge twice
before landing on the real one.
**What ResolutionFlow actually is:** the structuring layer between conversational AI
and the way MSP techs work tickets. AI is great at producing answers; it is bad at
producing workflow-shaped output. ResolutionFlow gives the tech the AI they already
trust (Claude/GPT) but organizes the output into actionable structured steps,
records the session, captures customer-specific context, and turns the result into
PSA-formatted ticket notes — and optionally a runbook — without the tech writing
anything.
**Positioning line:** "the senior engineer looking over your shoulder."
## Demand Evidence
The founder is the first user. Senior Systems Engineer at an MSP, losing ~20
hours/week to cross-domain interruptions (systems engineer pulled into networking
problems and vice versa). At least 4 interruptions per day, with the time cost
concentrated in the gap between AI-conversation output and MSP-ticket workflow.
This is solving-your-own-problem demand evidence — strongest possible signal at
this stage. The 20 hrs/week figure is the founder's own time, not a hypothetical.
Every MSP shop with a senior tech and a junior tech has a version of this problem.
Telemetry signal (Phase 0.5 baseline accruing): captured flows pile up but are not
being re-used. This says capture works, retrieval doesn't — which means the
"hours-saved-via-re-use" number isn't yet generatable from existing data. The
GTM-grade ROI story needs a different metric until re-use lands: minutes recovered
per escalation, generated by Approach A below.
## Status Quo
MSP techs today resolve tickets via three workarounds:
1. **AI in a tab.** Junior tech opens Claude or ChatGPT, pastes the problem, gets a
wall of prose, parses it into action items in their head, executes, repeats. AI
does the diagnostic work. The tech does all the structure-extraction and
ticket-note-writing afterward.
2. **Tribal knowledge.** Junior tech pings senior in Slack. Senior tech is
interrupted (4+ times/day per the founder's own data). Context handoff is verbal
and lossy.
3. **Stale runbooks.** Half-maintained Notion / IT Glue / SharePoint pages that
nobody trusts because they're 18 months out of date and don't match the current
customer environment.
The cost of these workarounds for the founder personally: ~20 hours per week of
senior-tech time lost. For a 5-tech MSP, the equivalent is 1 full FTE worth of
senior-engineer hours leaking into context-switching and tab-hopping.
## Target User & Narrowest Wedge
**Target user:** Senior Systems Engineer at a small-to-mid MSP (5-20 techs). The
founder is exemplar #1. Buying authority is shared between senior tech (champion)
and MSP owner (signs the check).
**Narrowest paid wedge:** Escalation Mode. Single sharp feature. When a junior tech
escalates a ticket they were working in FlowPilot, the senior tech opens the ticket
and sees the entire structured session state — every step the junior tried, every
dead end, every command output — instead of starting with "tell me what you tried"
for five minutes.
Why this is the wedge:
- **Two metrics, not one** (revised after /codex review 2026-04-27):
- **Manual baseline** (the Assignment, weeks 0-2): senior tech stopwatches the
next 5 escalations. T1 (first diagnostic action) T0 (open ticket) under
today's verbal-handoff workflow. This is the "what you currently lose" number.
- **In-product metric** (telemetry, week 3+): time-to-first-action after claim,
derived from `ai_session_step` rows where `created_at > SessionHandoff.claimed_at`
AND `user_id = SessionHandoff.claimed_by`. This is the "what it is now with
structured handoff" number.
- **The savings claim** = manual baseline in-product metric. Quote both
explicitly in pilot conversations. Do NOT roll the in-product number alone
into "minutes recovered" — that's an apples-to-oranges miscount Codex caught
in the cross-model review.
- **Single-feature demo:** a 2-minute Loom shows the magic moment — junior hits
escalate, senior window opens with full structured context. No theory required.
- **Cross-buyer story:** sells to senior tech (less interruption) AND owner (junior
techs resolve faster, take more accounts).
- **Hours-saved math is simple:** 4-5 minutes per escalation × 15-30 escalations
per week per senior tech = 1-2 hours/week recovered per senior. At $80-150/hr
fully-loaded senior tech cost, the tool pays for itself with one customer.
## Constraints
- **One-founder shop.** Cannot run three concurrent product narratives. Sequence
matters more than scope.
- **Pre-PMF runway implied.** 4-8 week build cycles before talking to a buyer are
expensive. Approach A's 1-2 week timeline is the binding constraint.
- **Existing architecture is mostly aligned.** FlowPilot, unified_chat_service,
FlowProposal, ConnectWise PSA integration — most of the pieces exist. Risk is
positioning and UX, not capability.
- **PSA copilot competition is real.** ConnectWise / Autotask / Halo are racing to
ship AI features. The wedge has to be sharp because we lose on distribution.
## Premises
The five load-bearing claims this design rests on, all confirmed in session:
1. **Diagnostic AI is commoditized.** ResolutionFlow does not compete on
"AI solves the ticket faster." That race is over. ChatGPT/Claude already won.
2. **The structuring layer is the wedge.** AI conversational output is too dense
and unstructured for active troubleshooting. ResolutionFlow's value is
organizing that output into actionable, separable, recorded steps.
3. **Escalation context is the killer feature.** "Junior hits escalate, senior gets
full structured context in 30 seconds instead of 5 minutes" is the sharpest
demoable moment in the entire product surface.
4. **First paying customer is bottom-up, prosumer-flavored.** Senior tech at a
small MSP, $20-50/seat/month, monthly billing. Owner-targeted enterprise
pricing waits until 5+ paying shops establish baseline ROI numbers.
5. **Distribution is MSP communities, not paid SaaS ads.** r/msp, MSPGeek, RocketMSP,
PSA marketplace listings. The channel matches the buyer.
## Approaches Considered
### Approach A: Escalation Mode first (REDUCED SCOPE per /plan-eng-review)
Lead the GTM with the killer feature. Polish the escalate-with-context handoff:
junior tech mid-session hits escalate, senior tech window opens with full
structured session state. 2-min demo Loom. Pilot with **3 MSPs** in the founder's
network (capped at 3 to preserve build capacity for B). Metric: minutes recovered
per escalation.
**SCOPE REDUCTION (2026-04-27 eng review):** ~80% of Approach A is already built.
The original 2-3 week estimate assumed greenfield. Codebase audit confirms:
| What the doc said "build" | What actually exists |
|---|---|
| Session-state serialization | `ai_session.escalation_package` (JSONB), `SessionHandoff.snapshot` |
| Senior-tech inbox | [EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx) + [EscalationQueue.tsx](frontend/src/components/flowpilot/EscalationQueue.tsx) |
| Claim workflow | [handoff_manager.py:123 claim_session()](backend/app/services/handoff_manager.py#L123) |
| API surface | [session_handoffs.py](backend/app/api/endpoints/session_handoffs.py) — POST /handoff, /claim, GET queue |
| AI assessment for senior | `_generate_ai_assessment()` in handoff_manager |
| PSA round-trip | `escalation_package_markdown`, `escalation_package_external_id` |
**Real engineering scope (~6-9 days):**
1. **Notification dual-path** (4-5 days). `notification_sent` flag is a dead column —
never written. Wire two channels in `handoff_manager.create_handoff`:
- **Email** (existing `EmailService.send_notification_email`) — handles offline seniors.
- **WebSocket / SSE push** to the EscalationQueue for live demo magic moment.
- Set `notification_sent=true` after dispatch confirmation.
- Graceful degradation: handoff still created if notification raises (regression test required).
2. **Hero metric endpoint** (~2 hours). New `GET /api/v1/analytics/escalation-metrics`,
account-scoped, role-gated to `require_engineer_or_admin`. Computes
*minutes recovered per escalation* by querying:
```
ai_session_step.created_at (first row by senior_tech_user_id where created_at > SessionHandoff.claimed_at)
minus
SessionHandoff.claimed_at
```
Returns a rolling-30-day average per account. No schema change.
3. **UX polish on EscalationQueue + receiving-engineer view** (2-3 days). Confirm the
magic-moment screen lands when senior clicks claim. Add an unread indicator on
the queue. Wire optimistic insert when SSE event arrives.
4. **Loom + landing page copy** (1-2 days). Non-engineering. Outside this plan's scope
but required for the GTM in week 3.
**Test plan:** 100% coverage of new paths — 13 tests including 4 e2e and 1 regression
(graceful-degradation when notification dispatch raises). Test plan artifact at
`~/.gstack/projects/chihlasm-resolutionflow/abc-main-eng-review-test-plan-20260427-000000.md`.
**Risk:** Low. Single feature, single metric, architecture-aligned. The dual-path
notification is the only mildly novel surface; both halves use existing infra.
**Reuses:** `services/handoff_manager.py`, `services/escalation_package_generator.py`,
`models/session_handoff.py`, `models/ai_session.py`, `services/notification_service.py`,
`models/notification_log.py`, EmailService, EscalationQueuePage + EscalationQueue.
### UI Specifications (locked by /plan-design-review 2026-04-27)
**Magic-moment screen** (new, after Pick Up click): dedicated handoff-context view that
loads BEFORE the regular FlowPilot session view, then dissolves on first senior action.
Four sections, single frame:
1. **Problem summary** (top, 2-3 lines): junior's framing. Bricolage Grotesque h2.
2. **What's been tried** (left or middle column): structured list of `dead_ends_flagged[]`
and `steps_attempted[]` from `escalation_package` JSONB. Card-flat surface, IBM Plex.
3. **AI assessment** (right column): `ai_assessment_data` rendered as 3 fields —
`likely_cause`, `suggested_steps[]`, `confidence`. accent-dim badge for confidence.
4. **Start here** (primary CTA, electric-blue, ≥44px touch target): opens FlowPilot
session at the most-likely-next-step. Senior typing or clicking anywhere triggers
200ms fade-out and FlowPilot view fades in. Re-openable via "Show handoff context"
ghost button in FlowPilot toolbar.
**Hero metric ("minutes recovered per escalation"):** lives in TWO places:
- **Queue stat-card** (above EscalationQueue list on /escalations): compact, "X.X hrs
saved this month" + "click for details" affordance. Refreshes on queue load.
- **Dedicated `/analytics/escalations` page** (owner-facing): trend chart (4-week
rolling), per-tech breakdown, per-problem-domain segmentation. Engineer-or-admin
role-gated.
**Real-time arrival visual** (when WebSocket pushes a new escalation):
- New card slides in from above the list, 200ms ease-out CSS transition.
- Browser tab title prefixes with " (1) " / " (N) " when tab is backgrounded; clears
on focus.
- No sound. MUST respect `prefers-reduced-motion: reduce` (slide-in collapses to
instant fade-in).
**Unread state:** subtle 6px dot in top-right corner of card for escalations the
current senior has never opened. Dot fades on first hover or click.
**Race-condition (two seniors click Pick Up simultaneously):** loser sees a toast
"Already claimed by [name] 2s ago" via existing `@/lib/toast`; the card flashes the
winner's name in the meta row for 1s, then dissolves from the loser's view via
optimistic update + WebSocket reconciliation.
**Unread state (Codex correction 2026-04-27):** dot indicator clears on **open,
claim, or explicit dismiss** — NOT on hover. Hover-to-clear is a bad proxy for
acknowledgment because incidental mouse movement creates false clears.
**Notification routing (Codex finding 2026-04-27):** v1 fans out the email + push
to **all engineer-or-admin role users in the same account_id as the SessionHandoff**.
No on-call/round-robin logic in v1. If pilots ask for routing, capture as v2 TODO.
The first senior to claim wins; everyone else's notification self-resolves on
WebSocket reconciliation.
**Notification delivery model (Codex correction 2026-04-27):** drop the
`notification_sent: bool` flag from v1. Replace with per-channel delivery rows
in a new `notification_log` table (already exists — reuse, don't add a new model)
keyed by `(handoff_id, channel, recipient_user_id, status)` where status ∈
{queued, sent, failed, suppressed}. This makes partial-success and per-channel
retry visible. If the existing `notification_log` schema doesn't match, defer
the per-channel persistence to a v2 TODO and v1 logs delivery attempts to the
existing telemetry stream instead. Do NOT keep the dead boolean.
**"Start here" CTA (Codex correction 2026-04-27):** opens the FlowPilot session
at the **latest known state** (the AI's most recent agent_message + the current
pending_task_lane). Surface `ai_assessment_data.suggested_steps[]` as a list of
chips below the chat input — clicking a chip prefills the input. Do NOT invent a
"jump to most-likely-next-step" capability that doesn't exist in the session model.
**`/claim` role gate (Codex correction 2026-04-27, IN-SCOPE for v1):** add
`require_engineer_or_admin` dep on POST `/handoffs/{id}/claim`. Originally
deferred to TODO during eng review; Codex correctly flagged it as wedge-relevant
because the race-condition story depends on auth gating. ~30 min change. Removed
from TODO.md.
**A11y requirements (mandatory before pilot ship):**
- Keyboard: Tab order through queue cards; Enter on focused card opens it; Pick Up
button is a reachable target; Esc closes the handoff-context overlay.
- ARIA: `role="region"` + `aria-live="polite"` on the queue list (announces arrivals);
`aria-label="N escalations awaiting pickup"` on the heading; the slide-in animation
must not announce twice (debounce live-region updates).
- Pick Up button: bump from `py-2` to `py-2.5` to clear the 44px touch-target floor.
- Color contrast: confidence-badge text on accent-dim background must be ≥4.5:1
(verify against DESIGN-SYSTEM.md tokens).
**DS token discipline:** every new piece must use `card-flat`, `accent-dim`/`accent-text`,
`text-muted-foreground`, `bg-card`/`bg-elevated`, IBM Plex / Bricolage / JetBrains,
explicit `transition` property lists (never `transition: all`). No glass, no blur,
no gradient surfaces. Electric-blue accent reserved for interactive elements only.
**Mobile responsive:** deferred to post-pilot TODO. Pre-PMF wedge target is desktop;
MSP techs work on laptops/desktops in shop environments.
**Deferred to TODO.md (out of scope for v1 wedge):**
- Peer-tech escalates colleague's session (currently session-owner-only)
- Role gate on POST /claim (currently any authenticated user in account)
### Approach B: Full Structured Resolution loop (split B1 + B2)
End-to-end demo: tech opens FlowPilot, structure appears in side panel as AI
responds, ticket notes auto-populate at end, optional runbook capture for reusable
patterns. Tells the full "senior engineer over your shoulder" story.
**B1 — Side panel + PSA-formatted ticket notes** (ships first):
- Structured side panel that surfaces parsed AI markers as live actionable steps
while the conversation runs.
- PSA-formatted ticket-notes exporter (ConnectWise first; Autotask/Halo later).
- Effort: M (~3 weeks).
**B2 — Runbook offer-and-save** (gated on pilot demand):
- "Save this resolution as a flow?" prompt at session end, with auto-drafted
runbook from the structured session state.
- Effort: S (~1 week). Don't build until at least 2 pilot customers explicitly
ask for it.
- **Risk:** Medium. The structured-output panel quality is the whole demo. If it
looks dumb, the demo dies.
- **Reuses:** FlowPilot, unified_chat_service, FlowProposal, ConnectWise PSA
integration.
### Approach C: Senior-Tech Time-Saved Counter
Continuous measurement layer underneath A and B. Every session contributes an
estimated minutes-saved number. Owner-facing dashboard quotes "this month your
shop saved N hours of senior-tech time." Sells to MSP owner with verifiable ROI.
- **Effort:** S (~1 week + ongoing measurement methodology refinement).
- **Risk:** Medium-low. Methodology has to be defensible. If numbers look
made-up, trust dies fast.
- **Reuses:** FlowPilot telemetry, session metadata, account-scoped analytics.
## Recommended Approach
**A first (1-2 weeks), then B (3-4 weeks after A ships), with C running underneath
both as a continuous backdrop.**
Sequence rationale:
- **A is the sharpest possible 2-minute demo.** Single feature, single metric,
buyer-verifiable in their own data. Get it in front of 5 MSPs in week 3.
- **B is the depth play.** Once Approach A has produced first-pilot signal,
Approach B's full structured-resolution loop becomes the "what we ship next" that
retains pilots and converts them to paid.
- **C compounds across both.** Every session under A or B contributes to the
time-saved counter. By week 6 there are real numbers to put in front of an MSP
owner — turning a senior-tech-led pilot into an owner-signed contract.
This sequence is non-negotiable. Building B before A is the classic pre-PMF trap of
perfecting product before validating GTM. Building C alone is measurement without a
demo to anchor it.
## Pricing
**Pilot pricing (first 3-5 customers): $39/seat/month, monthly billing,
month-to-month.** Anchored against IT Glue (~$29/tech), Hudu (~$25/tech),
Liongard (~$3/endpoint). The premium over IT Glue/Hudu reflects the active-session
value (vs. their static-runbook value) — 30% above the runbook-only category.
Customer #6+ pricing is an Open Question (revisit after 3 pilots produce real
hours-saved data; price up if the per-seat ROI is over $200/seat/mo).
## Open Questions
1. **Free-tier shape.** Should the time-saved counter be free forever as a
distribution lever, with paid for the structuring + escalation? Land-and-expand
pattern. Decide after 3 pilot conversions.
2. **PSA-marketplace timing.** ConnectWise Marketplace listing requires partnership
onboarding (~6-week cycle). Submit application week 5; expect listing live by
week 11. Don't gate launch on it.
3. **Customer #6+ pricing.** Revisit after 3 pilot customers produce verifiable
hours-saved numbers.
## Deferred (YAGNI until 10 paying customers)
- HIPAA / SOC2 audit positioning. Pre-PMF is too early; revisit when a regulated-
vertical MSP asks for it explicitly.
- Multi-PSA depth (Autotask, Halo). ConnectWise alone covers ~40% of the SMB MSP
market and is sufficient for first 5-10 customers.
- Cross-tenant pattern detection. The data-flywheel-across-shops play is at least
6 months out; building it before single-shop ROI is proven is premature.
## Success Criteria (revised for realism)
- **Week 3:** Approach A shipped. 3 MSPs in active free pilot (cap at 3 to
preserve B1 build capacity).
- **Weeks 3-6:** Pilot management dominates. B1 build is paused; founder runs
pilot calls, captures bug reports, iterates UX. Stripe seat-based billing is
set up in week 5.
- **Week 6:** First verbal commit from a pilot customer. Verified
minutes-recovered-per-escalation number from at least 2 pilots.
- **Week 8:** First paid customer (procurement cycles run 4-6 weeks even at small
MSPs; 2 weeks from verbal commit to signed contract is realistic). Time-saved
counter (Approach C) producing dashboard-quality data.
- **Week 11:** B1 (side panel + PSA notes) shipped. 3-5 paying customers. First
MSP-owner-led conversation. ConnectWise Marketplace listing live.
- **Quarter end:** $5K MRR or 10 paying customers, whichever comes first. Loom
demos posted publicly to r/msp and MSPGeek.
## Distribution Plan (week-by-week cadence)
- **Week 3:** Escalation Mode demo Loom posted. r/msp launch post.
- **Week 4:** MSPGeek Discord AMA scheduled. RocketMSP newsletter pitch sent.
- **Week 5:** ConnectWise Marketplace listing application submitted. Stripe
billing live for paid conversion.
- **Week 6:** First "guest on Inside MSP podcast" outreach. Second r/msp post
(case study from a pilot, anonymized).
- **Week 7-8:** Pilot conversion calls. First paying customer.
- **Week 9-11:** B1 ships. Owner-targeted demo Loom. Second podcast outreach.
**Founder-led pilot:** The first 3-5 customers come from the founder's existing
MSP network. Treat them as design partners; expect to ship feature requests
weekly during pilot. Cap at 3 active pilots until B1 ships.
**Tech audience channels:** r/msp, r/sysadmin, MSPGeek Discord, RocketMSP
newsletter, Inside MSP podcast.
**Owner audience channels:** ConnectWise Marketplace, MSP-focused Substacks,
RIA Vendor Roundup.
CI/CD: existing Railway auto-deploy via GitHub mirror. No new pipeline needed.
## Dependencies
- **Session-state serialization (Approach A blocker).** Schema design + migration
is the longest-lead engineering task. 3-5 days budget. Do this first.
- **Stripe seat-based billing (week 5 task).** No billing infrastructure exists
today. ~3-5 days of work for monthly subscriptions + invoice flow. Block on
this before week-8 first-paid milestone.
- **ConnectWise PSA integration depth.** Sufficient for ticket-notes auto-export
(Approach B1). Autotask and Halo wait until first 5 paying ConnectWise
customers.
- **Authentication.** Existing JWT + role hierarchy is sufficient for senior-tech
inbox view; no new auth work needed.
## Risks and Kill-Switch
- **Risk: Session-state serialization design churn.** If the schema needs to
change after pilot feedback, every saved session has to migrate. Mitigation:
keep schema versioned and forward-compatible from day 1.
- **Risk: Pilot-to-paid conversion slower than 4-6 weeks.** MSP procurement is
notoriously slow. Mitigation: get verbal commits in writing; price as
month-to-month with no annual contract to lower the buying friction.
- **Risk: ConnectWise ships an equivalent feature in their 2026.x release.**
Mitigation: lead the marketing on "we're independent of your PSA" — works with
any PSA, not just ConnectWise. The founder's PSA-agnostic FlowPilot is an
asset here.
- **Kill-switch criterion:** if 0 of 3 pilots produce a verifiable
hours-saved-per-week number above 1.0 by week 8, **revisit the wedge**. The
product may need to pivot to deterministic-ops territory (Read 1 from the
session) or be repositioned. Don't sink another quarter into the current GTM
story without this number.
## The Assignment
**This week, before any code:**
Time-track the next 5 escalations in your shop manually. For each, capture:
1. Time the senior tech opens the ticket
2. Time the senior tech takes their first diagnostic action (not counting the
verbal "tell me what you tried" warm-up)
3. The delta — that's the wasted time per escalation today
Average those 5 numbers. **That's the hero stat in your first sales conversation:**
"Senior techs at our shop wasted N minutes per escalation just getting up to
speed. We built the thing that takes that to zero."
Don't try to pull this from telemetry — the doc itself notes that retrieval/re-use
data isn't queryable yet. Manual stopwatch on the next 5 escalations is the
fastest path to a defensible number.
This is the assignment because it forces the GTM story into the same time-zone as
the build, and it's a one-day effort that compounds for every conversation
afterward.
## What I noticed about how you think
- You contradicted my framing twice in the same session and the second
contradiction was sharper than the first. Most founders agree with the
diagnostic and walk out with a polished version of what they came in with. You
said "I'm just questioning if flows are even the way to go" — and that
sentence reset the entire wedge. That's craft.
- "The senior engineer looking over your shoulder" came out of you spontaneously,
not as a prepared pitch. That's the line. Use it. It survives because it's
emotional truth (every junior tech has had this, every senior tech has been
this), not constructed marketing copy.
- You're solving your own problem with your own time. 20 hrs/week isn't a
hypothetical user pain — it's your Tuesday. Founders who solve their own pain
ship sharper products because the feedback loop is instant.
- The escalation feature emerged from your description, not mine. I was busy
cataloging documentation pains. You said "junior to senior escalation? no
worries there either" almost as an afterthought. That afterthought is the wedge.
Pay attention to which features you describe casually versus which you push hard
on — the casual ones are sometimes where the truth lives.
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
| Codex Review | `/codex review` | Independent 2nd opinion | 1 | INFO | 12 findings, 6 applied, 1 partial, 5 rejected |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 2 issues, 0 critical gaps, scope reduced |
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL) | score 6/10 → 9/10, 8 decisions |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
- **CODEX:** 12 findings reviewed. Applied: 2-metric framing (#2), notification routing spec (#3), per-channel delivery model (#4), unread-state fix (#11), Start-here CTA reframe (#9), claim role gate moved in-scope (#8). Rejected: full scope reduction to PSA-brief-only (#6/7/12 — user kept queue UI as demo hero). Partial: scope concern (#5) acknowledged in eng review's email-first/polling-fallback. Misread: #1, #10.
- **CROSS-MODEL:** Claude (eng + design reviews) and Codex agree on 6/12 findings. The major disagreement was scope — Codex argued for cutting the queue UI, user rejected. Both agree on metric definition, notification routing, claim auth gating.
- **UNRESOLVED:** 0
- **VERDICT:** ENG + DESIGN CLEARED, CODEX REVIEWED — ready to implement.

View File

@@ -0,0 +1,33 @@
# Test Plan
Generated by /plan-eng-review on 2026-04-27
Branch: main
Repo: chihlasm/resolutionflow
## Affected Pages/Routes
- `/escalations` ([EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx)) — senior-tech inbox view; verify queue list, real-time arrival, click-through
- `/pilot/:session_id` (FlowPilotSessionPage) — verify post-claim load shows full escalation context (snapshot, ai_assessment, escalation_package)
- `GET /api/v1/analytics/escalation-metrics` (NEW) — verify hero metric calculation, account-scoping, role gate
## Key Interactions to Verify
- Junior tech clicks **Escalate** in active FlowPilot session → handoff is created → notification fires → senior sees escalation in queue within 30 seconds
- Senior tech clicks **Claim** in queue → session reactivates → senior is redirected into FlowPilot session view → ai_assessment + snapshot are visible
- Senior types first message in chat after claim → metric query starts attributing time-to-first-action
- MSP owner opens analytics page → "minutes recovered per escalation" widget shows current month's rolling average
## Edge Cases
- **Two seniors race to claim** the same handoff → one wins, the other gets a "Already claimed by [name]" message
- **Senior is offline** when escalation fires → email arrives via existing `EmailService.send_notification_email`
- **WebSocket disconnects mid-session** → frontend reconnects; missed events backfilled by re-fetching the queue
- **Notification dispatch raises** (SMTP down, WebSocket fanout fails) → handoff is still created (graceful degradation)
- **Senior takes non-chat action first** (e.g., posts directly to PSA) → metric falls back to PSA writeback timestamp or remains null; doc the chosen behavior
- **Account-scoped multi-tenancy** → senior at MSP A cannot see escalations from MSP B (Phase 4 RLS)
- **Role gate on metric endpoint** → only `engineer_or_admin` can hit `/escalation-metrics`
## Critical Paths
1. **Magic-moment demo flow** (the entire Loom): junior escalate → senior notification → senior claim → session view → first action recorded → metric updates
2. **Email fallback** when senior is offline — must not silently drop
3. **Regression: handoff creation succeeds even if notification dispatch raises** — graceful degradation is mandatory

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
# PSA Ticket Management — Design Spec
**Date:** 2026-04-16
**Status:** Approved
**Author:** Michael Chihlas + Claude
---
## Overview
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
---
## Decisions Made
| Question | Decision |
|----------|----------|
| Where does ticket management live? | Both: dedicated `/tickets` page + dashboard widget on QuickStartPage |
| List layout | Flat list with rich filters + pagination |
| Row density | Compact single-line rows |
| Ticket detail | Right-side slide-out panel (~50% width) |
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
| Resource member list | All CW members, RF-mapped users visually highlighted |
| Architecture | Dedicated `ticket_service.py` + normalized DTOs |
---
## Section 1 — Backend
### New Endpoints
All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app/services/ticket_service.py`.
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/integrations/psa/tickets` | Create a ticket |
| `PATCH` | `/integrations/psa/tickets/{id}/status` | Update ticket status |
| `GET` | `/integrations/psa/tickets/{id}/resources` | List current assignees |
| `POST` | `/integrations/psa/tickets/{id}/resources` | Add a resource (member) |
| `DELETE` | `/integrations/psa/tickets/{id}/resources/{member_id}` | Remove a resource |
| `POST` | `/integrations/psa/tickets/ai-parse` | Natural language → structured pre-fill payload |
**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.**
The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper).
Current callers that must be migrated:
- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type
- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type
- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response
- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response
All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged.
### ticket_service.py
New service wrapping the PSA provider for ticket mutations. Keeps `integrations.py` clean and PSA-agnostic for future Autotask support.
Methods:
- `create_ticket(account_id, payload) → PSATicketCreated`
- `add_resource(account_id, ticket_id, member_id) → PSAResource`
- `remove_resource(account_id, ticket_id, member_id) → None`
- `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate`
- `list_resources(account_id, ticket_id) → list[PSAResource]`
### PSA Provider — New Abstract Methods and Paginated Result Type
**New type in `backend/app/services/psa/types.py`:**
```python
@dataclass
class PaginatedTicketResult:
items: list[PSATicket]
total: int
page: int
page_size: int
```
**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`:
```python
# base.py
@abstractmethod
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ...
```
**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests:
1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page
2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }`
Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`.
**New abstract methods** added to `PSAProvider` base and `ConnectWiseProvider`:
```python
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
```
`update_status` already exists on the provider — no change needed there.
ConnectWise implementation:
- `list_resources``GET /service/tickets/{id}/members`
- `add_resource``POST /service/tickets/{id}/members`
- `remove_resource``DELETE /service/tickets/{id}/members/{member_id}`
- `create_ticket``POST /service/tickets`
### Normalized DTOs (Pydantic Schemas)
New schemas in `backend/app/schemas/psa_tickets.py`:
```python
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str # CW username
is_rf_user: bool # True if mapped in RF member mappings
class PSATicketCreated(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource]
class PSATicketStatusUpdate(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponse(BaseModel):
items: list[PSATicketSearchResult] # existing schema
total: int
page: int
page_size: int
```
`search_tickets` endpoint updated to return `TicketListResponse` (was a plain list). Backend sorts results by `priority desc, dateEntered desc` via CW `orderBy` param.
### AI Parse Endpoint
`POST /integrations/psa/tickets/ai-parse`
Request:
```json
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
```
Response — all pre-fill fields nullable, explicit `missing_fields` and `warnings`:
```json
{
"summary": "Outlook not syncing",
"company_id": 42,
"board_id": null,
"priority_id": null,
"status_id": null,
"assigned_member_id": 17,
"description": "User reports Outlook calendar not syncing since yesterday morning.",
"missing_fields": ["board_id", "priority_id", "status_id"],
"warnings": ["Could not determine board from context"]
}
```
Frontend uses `missing_fields` to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
---
## Section 2 — Frontend Architecture
### New Files
| File | Purpose |
|------|---------|
| `pages/TicketsPage.tsx` | Main tickets page — filter bar + paginated list |
| `components/tickets/TicketListRow.tsx` | Compact single-line row |
| `components/tickets/TicketFilterBar.tsx` | Config-driven filter bar (7 filters) |
| `components/tickets/TicketDetailPanel.tsx` | Slide-out panel orchestrator |
| `components/tickets/detail/TicketDetailHeader.tsx` | ID, summary, company, board, SLA |
| `components/tickets/detail/TicketResourceManager.tsx` | Assignee list + add/remove |
| `components/tickets/detail/TicketNotesFeed.tsx` | Chronological notes history |
| `components/tickets/detail/TicketAddNote.tsx` | Inline note composer |
| `components/tickets/detail/TicketConfigs.tsx` | Attached devices/configs |
| `components/tickets/detail/TicketRelated.tsx` | Related tickets list |
| `components/tickets/NewTicketModal.tsx` | Two-tab modal (owns draft state) |
| `components/tickets/AiTicketParseForm.tsx` | Prompt input → emits parsed values upward |
| `api/tickets.ts` | All ticket API calls (typed, `.then(r => r.data)` pattern) |
| `types/tickets.ts` | TypeScript interfaces mirroring normalized DTOs |
### Existing Files Touched
- `router.tsx` — add `/tickets` route (lazy, via `lazyWithRetry`)
- `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section
- `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header
- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4).
### Shared Types (`types/tickets.ts`)
```typescript
export interface TicketFilters {
search: string;
board_id: number | null;
status_id: number | null;
priority: string | null;
company_id: number | null;
assigned: 'me' | 'unassigned' | 'all' | number; // number = specific member_id
include_closed: boolean;
}
export interface TicketCreationPayload {
summary: string;
company_id: number | null;
board_id: number | null;
status_id: number | null;
priority_id: number | null;
description: string;
assigned_member_id: number | null;
}
export interface AiParseResponse {
summary: string | null;
company_id: number | null;
board_id: number | null;
priority_id: number | null;
status_id: number | null;
assigned_member_id: number | null;
description: string | null;
missing_fields: string[];
warnings: string[];
}
export interface PSAResource {
member_id: number;
member_name: string;
member_identifier: string;
is_rf_user: boolean;
}
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
// Re-export or import from there — do not redefine
export interface TicketListResponse {
items: PSATicketSearchResult[];
total: number;
page: number;
page_size: number;
}
```
### TicketsPage — Filter & Pagination State
All filter and pagination state lives in URL query params via `useSearchParams`:
| Param | Type | Default |
|-------|------|---------|
| `search` | string | `""` |
| `board` | number | — |
| `status` | number | — |
| `priority` | string | — |
| `company` | number | — |
| `assigned` | `me \| unassigned \| all \| {id}` | `all` |
| `closed` | boolean | `false` |
| `page` | number | `1` |
Filter changes reset `page` to 1. Pagination: page size of 25. Controls show "Showing XY of Z tickets". Next disabled when `page * 25 >= total`.
### TicketFilterBar — Config-Driven
Filters defined as a `FILTER_CONFIG` array. Each entry:
```typescript
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
```
Adding or removing a filter is a one-line config change, not a component edit.
### TicketDetailPanel — Optimistic Hydration
The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints.
1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
2. Two parallel fetches fire on open:
- `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets
- `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint)
3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves
4. Resources section renders skeleton until `listResources` resolves
`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed.
### NewTicketModal — State Ownership
- `NewTicketModal` owns the `TicketCreationPayload` draft state
- `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial<TicketCreationPayload>)` upward
- Modal merges parsed values into draft, highlights `missing_fields` with visual indicators
- Two tabs: **Quick Create** (AI prompt → review) | **Full Form** (manual entry)
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
- Initial props: `initialValues?: Partial<TicketCreationPayload>` — used for spin-off pre-population
---
## Section 3 — ResolutionAssist Integration
### Two Trigger Paths
**1. AI-suggested (via `[ACTIONS]` marker)**
When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`):
```
[ACTIONS]
[
{
"label": "Create ticket: Printer offline on 2nd floor",
"command": "create_spin_off_ticket",
"description": "Printer offline on 2nd floor"
}
]
[/ACTIONS]
```
The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input.
`summary_hint` (from `action.description`) populates the AI prompt input only, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
**2. Engineer-initiated**
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens `NewTicketModal` with Full Form tab as default.
### Both Paths — NewTicketModal Pre-population
**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects.
**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields:
```typescript
export interface PSATicketInfo {
id: string
summary: string
company_name: string | null
board_name: string | null
status_name: string | null
priority_name: string | null
company_id: number | null // add
board_id: number | null // add
}
```
These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through.
**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field:
```typescript
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
```
When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready.
Once `linkedTicket` is populated, the modal receives:
```typescript
initialValues: {
company_id: linkedTicket.company_id,
board_id: linkedTicket.board_id,
}
```
When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors.
### TaskLane Action Lifecycle
- Opening the modal does **not** remove the action from TaskLane
- Dismissing the modal without submitting leaves the action visible
- Successful ticket creation removes the action and shows a success toast: `"Ticket #1042 created in ConnectWise"`
### System Prompt Addition
New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
> When you identify a second distinct issue that is clearly separate from the primary topic of this session, suggest creating a spin-off ticket using the `[ACTIONS]` marker. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely separate — do not suggest for every tangential mention.
### Backend
- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above)
- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action
- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope
No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`.
---
## Section 4 — Dashboard Widget (QuickStartPage)
### Placement
`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible.
### Data Fetching
On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user.
`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change.
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
### Widget States
| State | Condition | Display |
|-------|-----------|---------|
| Hidden | No PSA connection | Widget not rendered |
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → `/account/integrations` |
| Loading | Fetching | 3 skeleton rows |
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
| Error | PSA fetch fails | Silent — returns `[]`, no toast (per Lesson 111) |
### Row Display
Compact row matching Tickets page style: `#ID · Summary · Status badge · Priority dot`
Clicking a row opens `TicketDetailPanel` as a right-side sheet rendered at the `QuickStartPage` level. Does **not** navigate away.
### "View All Tickets" Link
Links to `/tickets?assigned=me`. `TicketsPage` reads `assigned` from `useSearchParams` on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
### Sorting
Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the CW API query. Widget does not sort client-side.
---
## Files Changed Summary
### New Backend Files
- `backend/app/services/ticket_service.py`
- `backend/app/schemas/psa_tickets.py`
### Modified Backend Files
- `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse`
- `backend/app/services/psa/types.py` — add `PaginatedTicketResult` dataclass
- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult`
- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id`
- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema
- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT`
- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature)
- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format)
### New Frontend Files
- `frontend/src/pages/TicketsPage.tsx`
- `frontend/src/api/tickets.ts`
- `frontend/src/types/tickets.ts`
- `frontend/src/components/tickets/TicketListRow.tsx`
- `frontend/src/components/tickets/TicketFilterBar.tsx`
- `frontend/src/components/tickets/TicketDetailPanel.tsx`
- `frontend/src/components/tickets/NewTicketModal.tsx`
- `frontend/src/components/tickets/AiTicketParseForm.tsx`
- `frontend/src/components/tickets/detail/TicketDetailHeader.tsx`
- `frontend/src/components/tickets/detail/TicketResourceManager.tsx`
- `frontend/src/components/tickets/detail/TicketNotesFeed.tsx`
- `frontend/src/components/tickets/detail/TicketAddNote.tsx`
- `frontend/src/components/tickets/detail/TicketConfigs.tsx`
- `frontend/src/components/tickets/detail/TicketRelated.tsx`
### Modified Frontend Files
- `frontend/src/router.tsx``/tickets` route
- `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item
- `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file)
- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse`
- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo`
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
---
## Out of Scope
- Autotask provider implementation (schema-ready, not implemented)
- Time entry creation from ticket detail (provider method exists, no UI)
- Ticket editing beyond status (summary, description, priority changes)
- Bulk ticket operations
- Real-time ticket updates / polling

View File

@@ -0,0 +1,111 @@
import { expect, test } from '@playwright/test'
/**
* Regression test for the prefill-handoff `currentChatRef` bug.
*
* Symptom: a chat session created via the dashboard prefill flow
* looked fine on the first AI turn, but submitting partial answers
* from the task lane silently dropped the AI's follow-up response.
* The user saw their answers in the chat, no assistant reply, no
* toast.
*
* Root cause: the prefill effect in `AssistantChatPage` set
* `activeChatId` without also updating `currentChatRef.current`, so
* the `currentChatRef.current !== sentForChatId` guard in
* `handleTaskSubmit` (and `handleSend`) tripped on every subsequent
* request and discarded the AI response.
*
* Strategy: drive the real prefill flow against the real backend, but
* intercept the `/chat` endpoint with `page.route` so we get
* deterministic question payloads on turn 1 and a deterministic
* follow-up on turn 2. The fix is what makes turn 2 visible.
*/
test.describe('AssistantChatPage — prefill handoff regression', () => {
test('AI follow-up renders after submitting partial task lane answers', async ({ page }) => {
let chatCallCount = 0
// Clear any persisted active-chat-id so the page does not auto-resume a
// stale session left behind by a sibling spec.
await page.addInitScript(() => {
try {
sessionStorage.removeItem('rf-active-chat-id')
sessionStorage.removeItem('rf-tasklane-meta')
} catch { /* ignore */ }
})
// Intercept only the chat endpoint. Session creation, listSessions,
// facts, suggested-fixes, etc. all hit the real backend so the page
// renders normally — only the LLM call is deterministic. The pattern
// matches `/ai-sessions/<uuid>/chat` and nothing nested beneath it.
await page.route(/\/api\/v1\/ai-sessions\/[^/]+\/chat$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.fallback()
return
}
chatCallCount += 1
if (chatCallCount === 1) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
content: 'Initial diagnostic plan. Please answer the questions in the task lane.',
suggested_flows: [],
fork: null,
actions: [],
questions: [
{ text: 'Has the user recently changed their password?' },
{ text: 'Is the lockout happening at a consistent time of day?' },
],
}),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
content: 'Got it — based on your answer, here is what to check next.',
suggested_flows: [],
fork: null,
actions: [],
questions: [],
}),
})
})
// Drive the prefill flow exactly the way the dashboard does. The textarea
// is keyed by its placeholder copy on QuickStartPage.
await page.goto('/')
const prefillBox = page.getByPlaceholder(/Describe the issue/i)
await expect(prefillBox).toBeVisible({ timeout: 10_000 })
await prefillBox.fill('User locked out of AD weekly')
await prefillBox.press('Enter')
// After the prefill submits we land on /pilot and the first stubbed AI
// turn surfaces the task-lane question text.
await expect(page).toHaveURL(/\/pilot/)
await expect(
page.getByText('Has the user recently changed their password?'),
).toBeVisible({ timeout: 15_000 })
// Answer the first question. UI flow: click "Answer" to open the
// textarea, type, click the inline "Answer" button to mark done.
await page.getByRole('button', { name: /^Answer$/ }).first().click()
await page.getByPlaceholder('Type your answer...').fill('No, password is months old')
await page.getByRole('button', { name: /^Answer$/ }).first().click()
// Submit the partial response. Pre-fix: the response was silently dropped
// here because `currentChatRef.current` still held the mount-time value.
await page.getByRole('button', { name: /Send 1 of 2 Responses/ }).click()
// Bug repro: the assistant message must render. Pre-fix this assertion
// fails because `handleTaskSubmit` early-returns at the
// `currentChatRef.current !== sentForChatId` guard.
await expect(
page.getByText('Got it — based on your answer, here is what to check next.'),
).toBeVisible({ timeout: 15_000 })
// Both chat calls must have actually happened.
expect(chatCallCount).toBe(2)
})
})

View File

@@ -88,6 +88,8 @@ test.describe('command palette smoke tests', () => {
await flowpilotOption.click()
await expect(page).toHaveURL(/\/assistant/)
// Phase 1 of the FlowPilot migration renamed /assistant to /pilot.
// /assistant still 301-redirects to /pilot, so accept either landing URL.
await expect(page).toHaveURL(/\/(pilot|assistant)/)
})
})

View File

@@ -24,13 +24,21 @@ test.describe('session history smoke tests', () => {
await page.goto('/sessions')
await expect(
page.getByRole('heading', { name: 'Sessions', exact: true }),
page.getByRole('heading', { name: 'Session History', exact: true }),
).toBeVisible()
// Default tab on /sessions is "AI Sessions"; flow sessions live behind
// the "Flow Sessions" tab and only that tab exposes ticket/client filters.
await page.getByRole('button', { name: 'Flow Sessions' }).click()
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
await page.getByPlaceholder('Search by client name...').fill(clientName)
const sessionCard = page.locator('.bg-card').filter({ hasText: ticketNumber }).filter({ hasText: clientName }).first()
const sessionCard = page
.getByTestId('flow-session-card')
.filter({ hasText: ticketNumber })
.filter({ hasText: clientName })
.first()
await expect(sessionCard).toBeVisible()
await expect(sessionCard.getByText(tree.name)).toBeVisible()

View File

@@ -24,7 +24,7 @@ test.describe('flow library start-session smoke tests', () => {
await page.getByPlaceholder('Search flows...').fill(tree.name)
await page.getByRole('button', { name: 'Search', exact: true }).click()
const treeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first()
const treeCard = page.getByTestId('tree-card').filter({ hasText: tree.name }).first()
await expect(treeCard).toBeVisible()
await treeCard.getByRole('button', { name: /^Start(?: Session)?$/ }).click()

View File

@@ -20,7 +20,7 @@ test.describe('flow library smoke tests', () => {
await page.getByPlaceholder('Search flows...').fill(tree.name)
await page.getByRole('button', { name: 'Search', exact: true }).click()
await expect(page.getByText(tree.name)).toBeVisible()
await expect(page.getByTestId('tree-card').filter({ hasText: tree.name }).first()).toBeVisible()
} finally {
await disposeApiContext(api)
}

View File

@@ -14,7 +14,7 @@ test.describe('authenticated navigation smoke tests', () => {
await page.goto('/sessions')
await expect(
page.getByRole('heading', { name: 'Sessions', exact: true }),
page.getByRole('heading', { name: 'Session History', exact: true }),
).toBeVisible()
})
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
await page.goto('/account')
await expect(
page.getByRole('heading', { name: 'Account Settings' }),
page.getByRole('heading', { name: 'Account Management' }),
).toBeVisible()
})
})

View File

@@ -18,9 +18,17 @@ test.describe('session resume smoke tests', () => {
})
try {
await page.goto('/trees')
// Resume flow moved off /trees onto the Flow Sessions tab of /sessions
// during the FlowPilot migration. The destination (/trees/:id/navigate)
// is unchanged — only the entry point shifted.
await page.goto('/sessions')
await expect(
page.getByRole('heading', { name: 'Session History', exact: true }),
).toBeVisible()
await page.getByRole('button', { name: 'Flow Sessions' }).click()
// Active sub-tab is the default and surfaces in-progress sessions.
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).filter({ hasText: 'Resume' }).first()
const resumeCard = page.getByTestId('flow-session-card').filter({ hasText: tree.name }).first()
await expect(resumeCard).toBeVisible()
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()

View File

@@ -31,7 +31,7 @@ test.describe('shared session management smoke tests', () => {
).toBeVisible()
await expect(page.getByText(share.share_name || '')).toBeVisible()
const shareCard = page.locator('.bg-card').filter({ hasText: share.share_name || '' }).first()
const shareCard = page.getByTestId('share-card').filter({ hasText: share.share_name || '' }).first()
await shareCard.getByRole('button', { name: 'Revoke' }).click()
const confirmDialog = page.getByRole('dialog', { name: 'Revoke Share Link' })

View File

@@ -18,6 +18,9 @@ import type {
ChatSessionCreateResponse,
ChatMessageRequest,
ChatMessageResponse,
HandoffCreatedEvent,
HandoffAssessmentReadyEvent,
EscalationStreamHandlers,
} from '@/types/ai-session'
export const aiSessionsApi = {
@@ -220,6 +223,80 @@ export const aiSessionsApi = {
return response.data
},
// Native EventSource cannot send Authorization headers, so we use fetch +
// ReadableStream and parse SSE frames manually (same pattern as
// `streamDocumentation`). The returned promise resolves on clean stream
// close (server hangs up) and rejects on network/HTTP error so the caller
// can decide whether to reconnect with backoff.
async streamEscalations(
handlers: EscalationStreamHandlers,
signal: AbortSignal,
): Promise<void> {
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || ''
const response = await fetch(
`${baseUrl}/api/v1/ai-sessions/escalations/stream`,
{
headers: { Authorization: `Bearer ${token}` },
signal,
},
)
if (!response.ok) {
throw new Error(`Escalation stream failed: HTTP ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Escalation stream: no response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
// SSE frames are separated by blank lines. Hold the trailing partial
// frame in the buffer until the next chunk completes it.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
if (!frame) continue
let eventType = 'message'
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue // comment / keepalive
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
else if (line.startsWith('data: ')) data += line.slice(6)
}
if (!data) continue
try {
const parsed = JSON.parse(data) as Record<string, unknown>
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
} else if (
eventType === 'handoff_assessment_ready' &&
parsed.type === 'handoff_assessment_ready'
) {
handlers.onAssessmentReady?.(
parsed as unknown as HandoffAssessmentReadyEvent,
)
} else if (eventType === 'ready') {
handlers.onReady?.()
}
} catch {
// skip malformed frame
}
}
}
},
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
params: { q, limit },

View File

@@ -1,5 +1,12 @@
import apiClient from './client'
import type { FlowPilotDashboard, KnowledgeGapReport, CoverageResponse, FlowQualityResponse, EnhancedPsaMetrics } from '@/types/flowpilot-analytics'
import type {
FlowPilotDashboard,
KnowledgeGapReport,
CoverageResponse,
FlowQualityResponse,
EnhancedPsaMetrics,
EscalationMetrics,
} from '@/types/flowpilot-analytics'
export const flowpilotAnalyticsApi = {
async getDashboard(period: string = '30d'): Promise<FlowPilotDashboard> {
@@ -36,6 +43,13 @@ export const flowpilotAnalyticsApi = {
})
return response.data
},
async getEscalationMetrics(period: string = '30d'): Promise<EscalationMetrics> {
const response = await apiClient.get<EscalationMetrics>('/analytics/flowpilot/escalations', {
params: { period },
})
return response.data
},
}
export default flowpilotAnalyticsApi

View File

@@ -37,3 +37,4 @@ export { handoffsApi } from './handoffs'
export { resolutionsApi } from './resolutions'
export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'
export { ticketsApi } from './tickets'

View File

@@ -1,6 +1,7 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
import type { PSABoard, TicketLinkResponse, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
import type { TicketListResponse } from '@/types/tickets'
export const integrationsApi = {
getConnection: () =>
@@ -15,20 +16,22 @@ export const integrationsApi = {
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
listBoards: () =>
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
searchTicketsQueue: (params: {
assigned_to_me?: boolean
unassigned?: boolean
board_ids?: string
page?: number
page_size?: number
}) =>
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
}): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
getTicket: (id: string) =>
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
getTicketStatuses: (ticketId: string) =>
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
getBoardStatuses: (boardId: number | string) =>
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/boards/${boardId}/statuses`).then(r => r.data),
listMembers: () =>
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
getMemberMappings: () =>

Some files were not shown because too many files have changed in this diff Show More