Commit Graph

388 Commits

Author SHA1 Message Date
067574ad6a feat(ai): robust response extraction + structured-output foundation
Harden the Anthropic provider and lay the groundwork for schema-constrained
JSON, optimizing the existing claude-sonnet-4-6 / claude-haiku-4-5 usage
(no model changes).

ai_provider.py:
- _extract_text_from_response replaces fragile response.content[0].text:
  skips non-text leading blocks (e.g. thinking), returns the first text
  block, logs an anthropic.stop_reason warning on max_tokens/refusal
  (truncation now observable), and raises ValueError on a no-text response.
- generate_json gains an optional `schema` param. Anthropic wires it to
  output_config.format (structured outputs); schema=None preserves the exact
  prior call for every existing caller. Gemini accepts-and-ignores it.

kb_conversion_service.py:
- TROUBLESHOOTING_SCHEMA / PROCEDURAL_SCHEMA + _schema_for_target_type(),
  modelled as a strict superset of every field the prompts emit.
- convert_document passes the schema only when the new
  AI_KB_CONVERT_STRUCTURED_OUTPUT setting is True (default False). The
  _try_repair_json fallback stays as belt-and-suspenders.

Tests: 14 provider + 7 schema, TDD (red-green). Live constrained-decoding
smoke-test still required before enabling the flag in production.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:48:49 -04:00
c7cd711859 feat: AccountSecuritySettingsPage + active-users list + toast + login banner
Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).

Backend additions:
- accounts/me/security GET response gains active_users: list of
  {user_id, name, email, last_login_at} for users in this account
  with at least one un-revoked refresh token. Joined query on
  refresh_tokens + users, distinct, ordered by last_login desc.
  Drives the Active Sessions section.

Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
  auth store, returns warning ('none'|'soon'|'now') + reason
  ('idle'|'absolute') so consumers can pick the right UX for the
  closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
  fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
  button hits authApi.refresh() and updates the store on success.
  Absolute case: info-cyan tone, [Sign in now] link to /login (no
  recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
  the two bulk-revoke scopes. Title, body, and confirm-label vary by
  scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
  Header (Shield icon), intro, Policy card with Strict/Standard/Custom
  radios + always-visible-disabled Custom inputs (idle/absolute
  minutes) with inline validation, Save button + emerald success ping,
  info note about 'applies at next login'. Active sessions card with
  count-aware copy, list of {name, email, last-login-ago} rows
  (caller tagged '(you)'), two buttons — 'except me' hidden when
  count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
  owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
  ?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.

Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).

Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:07:14 -04:00
cabd745a2b feat(api): add POST /accounts/me/security/revoke-sessions
Sixth commit in the session-expiration-policy series. The kill-all-
sessions endpoint folded into scope after the §4.11 design pass.

- POST /accounts/me/security/revoke-sessions, owner-only.
- Body: {"scope": "all" | "others"}. Default "all" includes the caller's
  own refresh token. "others" preserves the caller's sessions so an
  owner can sign everyone else out without logging themselves out.
- Single SQL UPDATE through users.account_id -> refresh_tokens, with
  revoked_at IS NULL preserved as the gate so already-revoked rows
  don't get double-stamped (the idempotency property).
- Caller's access token is not touched — it dies on its 5-minute timer.
  Frontend handles "scope=all" UX by clearing localStorage and
  redirecting after the response (commit 8).
- Affected users' next /auth/refresh hits the existing atomic-revoke
  zero-rows path -> invalid_refresh_token (plain logout, no banner).
- Writes one account.sessions_revoked_bulk audit event with
  {scope, revoked_count}.

Tests added in test_session_policy.py (6 cases):
- #17 scope=all kills caller's own session; their refresh -> 401
  invalid_refresh_token.
- #18 scope=others preserves caller's session; their refresh succeeds,
  member's refresh -> 401 invalid_refresh_token.
- #19 account-scoped: test_admin in a different account is unaffected
  when test_user's owner runs revoke-all (revoked_count=1, not 2).
- #20 engineer-role member -> 403.
- #21 emits exactly one audit row with the expected payload.
- #22 idempotent: second immediate POST returns revoked_count=0.

22/22 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:31:10 -04:00
8cfaef6a9d feat(api): add GET/PATCH /accounts/me/security endpoint
Fifth commit in the session-expiration-policy series. Surfaces the
session-policy override controls to account owners.

- schemas/account_security.py: NEW. SessionPolicyResponse returns both
  the override (Optional[int]) and the effective value (always present)
  plus the system min/max bounds, so the frontend can render the
  Custom-preset form without re-implementing the defaults logic.
  SessionPolicyUpdateRequest accepts NULL to clear an override.
- endpoints/account_security.py: NEW. GET and PATCH on /me/security.
  Owner-only via require_account_owner. PATCH validates per-field
  bounds, then validates the effective idle <= absolute invariant
  (catching the partial-override case the DB CHECK can't see), then
  writes the row + an account.session_policy_update audit event with
  old/new/effective_old/effective_new payload.
- router.py: registers the new router under _tenant_deps next to
  accounts.router.

Tests added in test_session_policy.py (8 cases):
- GET returns NULL overrides + Strict defaults + system bounds.
- PATCH persists override; next login JWT reflects new values
  (60min/240min -> idle_max=3600, abs_max=14400 seconds).
- PATCH rejects idle < min (422).
- PATCH rejects absolute > max (422).
- PATCH rejects idle > absolute when both are set (422).
- PATCH rejects partial override that produces effective idle >
  effective absolute (idle=43200, absolute=NULL with default 20160).
- Engineer-role user gets 403.
- PATCH writes exactly one audit row with the expected payload shape.

16/16 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:28:51 -04:00
b21d2fc234 feat(auth): enforce absolute session cap in /auth/refresh
Fourth commit in the session-expiration-policy series. The gate that
ends "logged in forever" — refresh now rejects tokens whose original
login (auth_time) is older than abs_max seconds.

Algorithm (plan §4.5):
1. Decode JWT (dep already handles idle expiry).
2. Load user; reject inactive/missing as invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max, grandfathering
   pre-PR tokens by snapshotting current account policy.
4. Atomically revoke the JTI regardless of outcome — this consumes
   the token whether or not the absolute check passes, so an
   absolute-expired token cannot be replayed forever.
5. If the atomic UPDATE matched zero rows -> invalid_refresh_token.
6. If now >= auth_time + abs_max -> commit the revoke explicitly
   (so it survives the rollback hook in get_admin_db) and 401
   session_expired_absolute.
7. Otherwise mint via _mint_with_claims, carrying claims forward.

Boundary check uses `>=`, not `>` — a deadline equal to now is
expired. _refresh_session_tokens (commit 3) replaced by two narrower
helpers: _resolve_refresh_claims (grandfather logic, no mint) and
_mint_with_claims (mint with explicit claims, no grandfather). Makes
the endpoint's algorithm read top-down without indirection.

Tests added in test_session_policy.py:
- #8: backdate auth_time by exactly abs_max -> session_expired_absolute
  at the deadline boundary.
- #9: same token tried twice; first returns session_expired_absolute
  AND consumes the row; second returns invalid_refresh_token.
- #12: legacy token without auth_time/idle_max/abs_max gets one
  successful rotation; new JWT carries fresh policy snapshot from
  the account (3d/14d defaults under Strict).

25/25 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:26:00 -04:00
d6a02ee8da feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login
Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.

Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.

Key changes:
- core/security.py: create_refresh_token signature changes to
  (user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
  resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
  applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
  gain idle_expires_at + absolute_expires_at (Optional[datetime],
  Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
  _refresh_session_tokens(payload, user, db) helpers. /auth/login,
  /auth/login/json, and /auth/refresh now route through them. The
  refresh endpoint's pre-existing "Refresh token has been revoked"
  error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
  _mint_session_tokens; OAuthCallbackResponse carries the expiry
  fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
  the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
  surfaces matching ISO expiry fields; refresh carries auth_time,
  idle_max, abs_max forward unchanged across rotation.

35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:22:53 -04:00
2375948b7a feat(auth): distinguish idle expiry from invalid refresh tokens
Second commit in the session-expiration-policy series. Lands the
error-detail taxonomy from §4.10 of the plan; no UI-visible change yet
because the frontend interceptor (commit 7) doesn't read the new detail
strings, but the wire is now ready for it.

Today every /auth/refresh failure returns 401 "Invalid refresh token"
regardless of cause, so the frontend has no way to distinguish "your
session ended for security" from "we don't recognize this token at
all." This commit introduces:

- decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new
  IdleTokenExpired exception (from ExpiredSignatureError) so callers
  can branch on idle expiry. All other jose failures still propagate
  as JWTError. The legacy decode_token() is preserved for access-token,
  password-reset, and email-verification paths that don't need the
  distinction.
- get_refresh_token_payload(): now maps IdleTokenExpired ->
  "session_expired_idle", JWTError and wrong-type tokens ->
  "invalid_refresh_token".
- test_session_policy.py: new test file (will accumulate cases across
  the series). Three tests for the taxonomy: idle-expired returns
  session_expired_idle; wrong type returns invalid_refresh_token; bad
  signature returns invalid_refresh_token.

20/20 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:11:01 -04:00
92fa3bc6ab feat(auth): add session policy settings + account columns + migration
First commit in the session-expiration-policy series (see
docs/plans/2026-05-13-session-expiration-policy.md). No behavior change
yet — this lays the schema + settings groundwork only.

- Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d),
  SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds
  so account overrides have envelopes (15min..30d idle, 1h..90d
  absolute).
- accounts table: nullable session_idle_minutes and
  session_absolute_minutes columns (NULL = use system default), plus
  a CHECK constraint that rejects idle > absolute when both are set.
  Partial-override validation lives at the app layer because the DB
  cannot read Settings.

Subsequent commits will: distinguish idle vs invalid-token expiry on
the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce
the absolute cap in /auth/refresh, add the owner-only policy +
bulk-revoke endpoints, and surface everything in an AccountSecurity
settings page with a session-expiry toast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:52:21 -04:00
3f04911070 feat(billing): plan taxonomy reconciliation + Stripe sync + internal-tester allowlist (#164)
All checks were successful
CI / frontend (push) Successful in 6m40s
Mirror to GitHub / mirror (push) Successful in 7s
CI / e2e (push) Successful in 10m7s
CI / backend (push) Successful in 10m34s
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-11 05:07:07 +00:00
f1be3abcc5 feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00
79942c3fd3 feat(billing): add GET /billing/state aggregating subscription + plan + features
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
4768ae0648 feat(invites): add bulk-create and soft-revoke invite endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
e54d6c586a feat(invites): wire EmailService.send_account_invite_email into create handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
86893562b9 feat(auth): auto-send verification email on register; enforce invite email match
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
b0708ed650 feat(auth): guard login/password paths against OAuth-only users
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
2ef2350de7 feat(auth): add Microsoft OAuth callback
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
f4606f073a feat(auth): add Google OAuth callback with oauth_identities linking
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9b709488d9 feat(billing): extend Stripe webhook stub with concrete event handlers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
18180bc57f feat(billing): apply_subscription_event with stripe_events idempotency
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
f683bb5720 feat(billing): add /billing/checkout-session via BillingService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9851d56633 feat(billing): add BillingService.start_trial; wire into /auth/register
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
519c7eb5ce feat(deps): add require_verified_email_after_grace guard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9ec208f6e7 feat(deps): add require_active_subscription guard with allowlist
Mounts on Pro routers (trees, sessions, scripts, FlowPilot, etc.) and
returns 402 with structured detail when an account's subscription is
missing or locked. Allowlist bypasses billing/account/auth flows so
users can recover from a lapsed subscription.

Conftest now seeds a default Pro/active Subscription on test_user and
test_admin (delete-then-insert because the register endpoint already
creates a free/active sub by default). Two existing tests adapted to
the new seeded plan; tenant-isolation tests seed Subscription rows for
the accounts they create directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
cfe0e6cae6 refactor(deps): remove trial auto-downgrade; expiry now non-mutating per spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
e3f5ed4985 feat(billing): add complimentary status, fix is_paid, add has_pro_entitlement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
5105eaf529 feat(billing): add sales_leads and stripe_events tables
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
974b188c1e feat(billing): add plan_billing sibling table for Stripe + catalog metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
a28b635b19 feat(invites): add revoked_at + email_sent_at to account_invites
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
50e7763380 feat(onboarding): add accounts.team_size_bucket and primary_psa for wizard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
b3ed76c203 feat(onboarding): add users.role_at_signup and onboarding_step_completed
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
453ba3fefc feat(auth): make users.password_hash nullable for OAuth-only accounts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
143c979975 feat(auth): add oauth_identities table for Google/Microsoft sign-in
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
5bee264d70 fix(suggested-fix-pending): apply PR #156 review fixes
- Page-level Resolve patches applied_pending → applied_success before
  opening the resolution flow, so resolved sessions don't carry a
  provisional pending fix.
- Page-level Escalate intercept now catches applied_pending in addition
  to verifying/partial; intercept copy generalized from "Verifying state"
  to "still needs an outcome."
- PendingBanner gains a Dismiss action, matching the PR body and the
  backend's allowed pending → dismissed transition.
- resolution_note_generator and escalation_package_generator system
  prompts no longer include real-looking pending examples (anti-parrot
  guardrail compliance).

Verified via Docker: prompt anti-parrot 2/2, suggested-fix outcome suite
21/21, frontend tsc -b clean, npm run build clean.

Co-Authored-By: Codex <noreply@openai.com>
2026-04-30 23:02:46 -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
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
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
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
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
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
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
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
9bdd9959a8 fix(handoff): bound escalation assessment latency
Co-Authored-By: Codex <noreply@openai.com>
2026-04-27 20:03:14 -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
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
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
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