Compare commits
6 Commits
fix/seed-t
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bda590537 | ||
|
|
2dbb8b6abf | ||
|
|
9452e5d408 | ||
|
|
e002fe4969 | ||
|
|
7cbc9fe224 | ||
|
|
70242ad037 |
@@ -1,36 +0,0 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
|
||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
|
||||
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
|
||||
- **TaskLane keyboard-first flow** (real feature): Enter submits + auto-advances to next pending task, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last submission. Mouse path also auto-advances. Subtle hint row teaches the shortcut.
|
||||
- **Banner ↔ script panel linked**: collapsing or dismissing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; recording any outcome closes both surfaces.
|
||||
- **WhatWeKnow collapsible**: per-session preference in `sessionStorage` (`rf-whatweknow-collapsed:{sessionId}`); auto-collapses on first render at ≥5 facts.
|
||||
- **Side fix**: `ParameterizationPreview.tokenize()` word-boundary guard prevents over-eager highlighting of short values like `"D"` (no longer lights up every capital D in `Get-ADUser`).
|
||||
- Validation: tsc clean, ESLint clean, Vite build clean. Type-check + lint passed at every commit boundary.
|
||||
- **2026-05-01 — PR #156** Suggested-fix `applied_pending` non-terminal outcome. Merged into `main` as `3ba4532`. Adds:
|
||||
- Schema/API: `FixStatus="applied_pending"`, `pending_reason` Text column, migration `c0f3a4b7e91d`. `PATCH /suggested-fixes/{id}/outcome` accepts pending, requires notes, stamps `applied_at` only.
|
||||
- UI: `PendingBanner` (info-tone, worked / didn't / update reason / dismiss). "Waiting to verify…" overflow option in `VerifyingBanner`. Nudge "Still checking" records pending with a reason. Page-level Resolve auto-patches pending → success before resolution flow; page-level Escalate intercepts pending the same way verifying/partial does.
|
||||
- Generators: `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples.
|
||||
- Tests: 4 new in `test_fix_outcome_endpoint.py` (21/21 suite green); prompt anti-parrot guardrail green; tsc + Vite build clean.
|
||||
- QA report: `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` (5/7 scripted checks PASS with concrete evidence; 2 entry-path checks deferred — same handlers verified via tested transitions).
|
||||
- **2026-04-30 — PR #155** Escalation Mode wedge merged as `ac42f97`. Senior-tech 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.
|
||||
|
||||
## Notes for next session
|
||||
|
||||
- Drive checks 1 (VerifyingBanner overflow → "Waiting to verify…") and 5 (nudge "Still checking" with 3+ post-apply messages) in real pilot usage to close the QA gap left by `/qa` (the tested handlers cover the same mutations, but the entry-path UI rendering wasn't exercised end-to-end).
|
||||
- Consider monitoring how often pending fixes get parked vs resolved — if engineers report losing track across sessions, revisit the cross-session "Follow-ups" dashboard rollup that was scoped out.
|
||||
- After PR #158 lands in real ticket flow, eyeball the keyboard-hint contrast and the WhatWeKnow auto-collapse-at-5 threshold — both were judgment calls (5 was a guess; the contrast bump from `/70` to full muted-foreground was based on my read, not real screen testing). Adjust if the 5-fact threshold feels too aggressive or too lenient mid-session.
|
||||
- Two follow-ups logged in `.ai/TODO.md` from the impeccable pass: `ConcludeSessionModal` paused/escalated step should allow multi-select (Ticket Notes + Client Update + Email Draft simultaneously) — real feature work; `bg-card-hover` Tailwind class doesn't resolve in `CommandPalette` — two-line fix.
|
||||
138
.ai/DECISIONS.md
138
.ai/DECISIONS.md
@@ -1,138 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,36 +0,0 @@
|
||||
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
|
||||
|
||||
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
|
||||
|
||||
## Where this session ended
|
||||
|
||||
24 commits on top of `main` (`31ca3fb`). All 26 tasks from `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md` complete. Full pytest run is green (1167 passed, 35 deselected). Single alembic head: `c6cbfc534fad`.
|
||||
|
||||
Phase 1 covered: schema additions (oauth_identities, plan_billing, sales_leads, stripe_events, plus 5 new columns across users/accounts/account_invites), Subscription complimentary status + has_pro_entitlement, the two new guards (`require_active_subscription`, `require_verified_email_after_grace`), full BillingService (start_trial / create_checkout_session / apply_subscription_event / get_billing_state), Stripe webhook handler, Google + Microsoft OAuth callbacks with oauth_identities linking, OAuth-only password guard, register-time verification email + invite email-match, bulk + soft-revoke invite routes, GET /billing/state, and the pilot complimentary backfill migration.
|
||||
|
||||
The conftest's `test_user` fixture was modified to seed a Pro/active Subscription post-register (delete-then-insert) so the new subscription guard doesn't 402 every existing test. Two existing tests adapted because they explicitly assumed the old free-plan default: `test_subscription_limits.py` (the two free-plan tests now downgrade inline) and `test_kb_accelerator.py::TestQuota::test_get_quota` (the `kb_setup` fixture downgrades to free).
|
||||
|
||||
## Resume point — DO THIS NEXT
|
||||
|
||||
1. Open the PR for branch `feat/self-serve-signup-spec`. Use `gh pr create` against `main`. Suggested title: `feat: self-serve signup backend (Phase 1)`. Body should mention dark-launch posture (every new endpoint is gated by env config, not a feature flag — see Task 26 §3 in the plan).
|
||||
2. Phase 2 (frontend + cutover) lives in a sibling plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend.md` (assumed; verify path). It's the next logical task once Phase 1 ships.
|
||||
|
||||
## Followups deferred from this session
|
||||
|
||||
- **OAuth callbacks don't call `_store_refresh_token`.** The Google/Microsoft callbacks issue a refresh JWT but never persist its hash to `refresh_tokens` (the password-login flow does via `auth.py:_store_refresh_token`). Result: refresh-token revocation/rotation lookups won't find OAuth-issued tokens. Decide before Phase 2 dark-launch whether to backfill — likely yes, by extracting `_store_refresh_token` to a shared module and calling it from `_sign_in_or_register`.
|
||||
- **`stripe_enabled` was relaxed** in Task 14 from `bool(STRIPE_SECRET_KEY) and bool(STRIPE_WEBHOOK_SECRET)` to just the secret key. The webhook handler in Task 16 independently checks `STRIPE_WEBHOOK_SECRET` before calling `construct_event`, so signature verification is still safe — but if any other code reads `stripe_enabled` and assumes the webhook secret is set, that's a latent bug. Audit before Phase 2 cutover.
|
||||
- **`backend/app/core/stripe_handlers.py`** is a stub module that's no longer referenced after Task 16. Safe to delete in a follow-up; left in place to keep Phase 1 diff focused.
|
||||
- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only.
|
||||
|
||||
## Environment notes (carry-forward)
|
||||
|
||||
- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands.
|
||||
- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/<file>`, NOT `backend/tests/<file>`.
|
||||
- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/<path> -v --override-ini="addopts="`. The full run takes ~25 min.
|
||||
- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`.
|
||||
- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN` in `.claude/settings.local.json`) for PR/issue work, or run `gh` from a host that has it.
|
||||
- Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint).
|
||||
@@ -1,263 +0,0 @@
|
||||
# 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.
|
||||
|
||||
**On hosts without native `python`/`node`/`npm`** (e.g. the code-server LXC), run commands inside the already-running containers instead:
|
||||
|
||||
```bash
|
||||
docker exec resolutionflow_backend pytest --override-ini="addopts="
|
||||
docker exec resolutionflow_backend alembic upgrade head
|
||||
docker exec -w /app resolutionflow_frontend npm run build
|
||||
docker exec -w /app resolutionflow_frontend npx tsc -b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
@@ -1,42 +0,0 @@
|
||||
# .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.)
|
||||
@@ -1,303 +0,0 @@
|
||||
# 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-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
||||
|
||||
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
||||
- Rebuilt `frontend/src/data/guides.ts` from scratch as 43 problem-oriented Diátaxis how-tos under 10 categories. Single-outcome each, terse imperative steps, real UI labels (Create New, Sign in, Manage, Build New Script, Send Invite, Save Settings, Create Category, etc.). Added `category: CategoryId` and optional `relatedSlugs?: string[]` to the `Guide` interface; new `Category` type and `categories` const drive the hub layout. `GuidesHubPage` now renders category sections (auto-hides empty); `GuideDetailPage` renders a Related guides footer; `GuideCard` lost its misleading "N sections" subtitle.
|
||||
- Fixed `GuideSection.tsx`: `step.tip` was rendered as plain text so `**bold**` markdown in tips rendered literally. Applied the same regex replacement used on `step.instruction`. Verified against `/guides/start-a-session` tip block.
|
||||
- Authored 14 net-new how-tos for FlowPilot-era surfaces with no prior coverage: tasklane-keyboard-flow, view-what-we-know, ask-ai-mid-session, pause-and-leave-session, resolve-a-session, record-suggested-fix-outcome, escalate-a-session, post-docs-to-ticket, send-client-update, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate. Dropped change-teammate-role from scope — couldn't verify the role-change UI control without a non-owner test member.
|
||||
- Verified owner-only surfaces with `pro@resolutionflow.example.com`: Membership inline form on `/account` (not a separate `/team-members` route), `/account/categories` real button is **Create Category** (not Add), `/account/chat-retention` real fields are **Retention Period (days)** + **Max Conversations** + **Save Settings**, `/account/integrations` form fields confirmed. Three guides corrected post-audit.
|
||||
- Smoke-tested all 43 detail pages — every slug renders, no "Guide Not Found" fallthroughs.
|
||||
- Added `100.64.78.44 docker-01` entry to `/etc/hosts` (user ran `sudo tee` from a normal terminal because the LXC `!` shell prefix can't drive interactive sudo). Should now persist across `/browse` sessions on this LXC.
|
||||
- `docker exec -w /app resolutionflow_frontend npx tsc -b` clean.
|
||||
- Files touched: `frontend/src/data/guides.ts`, `frontend/src/pages/GuidesHubPage.tsx`, `frontend/src/pages/GuideDetailPage.tsx`, `frontend/src/components/guides/GuideCard.tsx`, `frontend/src/components/guides/GuideSection.tsx`, `CHANGELOG.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`. Working tree dirty — user not yet asked to commit.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-01 21:55 UTC — Claude — Session-screen impeccable pass + tasklane keyboard flow shipped (PR #158)
|
||||
|
||||
- Ran the `/impeccable` skill against the assistant chat session screen (chat history / chat bar / TaskLane). Initial design-health score: 24/40 with explicit DESIGN-SYSTEM violations (gradient surfaces in WhatWeKnow + ProposalBanner, side stripes in TaskLane done states + every banner mode, accent borderTop on lane header, backdrop blur on handoff overlay).
|
||||
- Walked through all 5 impeccable sub-passes (distill, quieter, layout, typeset, polish). Score after pass: 33/40 (+9). Biggest gains in Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), Recognition Rather Than Recall (2→4).
|
||||
- Inline iterations on top of the impeccable steps: linked banner ↔ script-panel lifecycle (collapse hides both, dismiss closes both, any outcome closes both); collapsible WhatWeKnow with `sessionStorage` memory + auto-collapse-at-5-facts; full keyboard flow on TaskLane (Enter submits + auto-advances, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last task).
|
||||
- Side fix: `ParameterizationPreview` was over-highlighting short parameter values (a `"D"` lit up every capital D in `Get-ADUser`/`Add-Type`/etc.). Added a word-boundary guard, conditional on whether the value itself starts/ends with a word character so values with leading punctuation (`"D:\\Folder"`) still match cleanly.
|
||||
- Followups logged in `.ai/TODO.md`: `ConcludeSessionModal` multi-select for paused/escalated outcomes (real feature work — engineers often need ≥2 of Ticket Notes / Client Update / Email Draft), and `bg-card-hover` Tailwind drift in `CommandPalette` (silently broken classes — two-line fix).
|
||||
- Branched as `feat/session-distill-quieter`, 4 commits (impeccable pass, parameterize fix, TODO followups, hint contrast + font-sans audit). PR #158 created via Gitea API (`$GITEA_TOKEN` env, no `gh` on this LXC). Merged into `main` as `5e10005`. Local branch deleted.
|
||||
- Validation at every commit boundary: `docker exec -w /app resolutionflow_frontend npx tsc -b`, `npm run lint`, and `npm run build` all clean.
|
||||
- Files touched: 14 frontend files (TaskLane, AssistantChatPage, ChatMessage, ProposalBanner, WhatWeKnow, WhatWeKnowItem, SuggestedFlowCard, ChatSidebar, ConcludeSessionModal, ChatTabStrip, ActionCardGroup, AddNoteButton, ParameterizationPreview), `.ai/TODO.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `CHANGELOG.md`, `CURRENT-STATE.md`.
|
||||
|
||||
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
|
||||
|
||||
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
|
||||
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
|
||||
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
|
||||
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
|
||||
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
|
||||
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
|
||||
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
|
||||
|
||||
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
|
||||
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
|
||||
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
|
||||
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
|
||||
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
|
||||
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
|
||||
|
||||
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
|
||||
|
||||
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
|
||||
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
|
||||
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
|
||||
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
|
||||
|
||||
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
|
||||
- Resolved a tooling gap surfaced by Codex's prior session ("npm/python/python3 are not on the host path") by documenting that this code-server LXC uses bun + docker for the toolchain. The `docker exec resolutionflow_{backend,frontend}` form is now the canonical command pattern in `.ai/PROJECT_CONTEXT.md`.
|
||||
- Got `$B`/Playwright Chromium running in the code-server LXC. After the user's restart cleared the AppArmor unprivileged-userns block, Chromium still aborted at the deeper `sandbox/linux/services/credentials.cc` layer because of the LXC namespace constraint. Workaround: launch browse with `CONTAINER=1` so it auto-adds `--no-sandbox`. Also added `100.64.78.44 docker-01` to code-server's `/etc/hosts` (via `docker exec -u 0`) so the headless browser could resolve the bake-in `VITE_API_URL`.
|
||||
- Drove `/qa` against the dev stack at `http://100.64.78.44:5173`. No naturally-occurring `applied_pending` fix existed in the DB, so seeded session `4a558056-bcbd-4b51-925b-248d70eb318d` and fix `cd4ff2fd-751a-4bcb-8cfa-3c77b4864fb2` into the test state (un-resolved session, swapped supersession on the two fixes). Saved a restore script first; verified DB matches pre-test state after teardown.
|
||||
- QA result: 5/7 scripted checks PASS with concrete DB + UI evidence. Banner renders correctly ("Awaiting verification" header, "Parked" tag, fix title + pending_reason, 4 actions). "Update reason" updates server-side. "It worked" → `applied_success` with `verified_at` stamped. "Dismiss" → `dismissed` with no terminal timestamp. Page-level Resolve auto-patches `applied_pending` → `applied_success` before the resolution flow opens. Page-level Escalate fires `EscalateInterceptDialog` with the generalized "still needs an outcome" copy. 2 entry-path checks (VerifyingBanner overflow, nudge "Still checking") deferred because they require live AI-generated chat state to drive; the mutating handlers behind those entry paths are verified via the tested transitions. Report at `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`.
|
||||
- Pushed `feat/fix-pending-verification`. Polled Gitea actions runs 161; required `CI / frontend` and `CI / backend` plus `CI / e2e` all green. Merged via Gitea API as a merge commit (`3ba4532`).
|
||||
- Post-merge cleanup: fast-forwarded local `main`, deleted `feat/fix-pending-verification` locally and on the remote. Wrote handoff updates on `chore/post-156-handoff` matching the prior `chore/post-153-handoff` pattern.
|
||||
- Files touched (this session): `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/SESSION_LOG.md`, `AGENTS.md`, `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`, `.gstack/qa-reports/screenshots/01-08*.png`. Plus the two prior-session-authored commits committed by this session (5 source + 3 `.ai/` notes).
|
||||
|
||||
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
|
||||
|
||||
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
|
||||
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
|
||||
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
|
||||
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
|
||||
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 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.
|
||||
25
.ai/TODO.md
25
.ai/TODO.md
@@ -1,25 +0,0 @@
|
||||
# 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
|
||||
|
||||
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. 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.
|
||||
|
||||
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
|
||||
|
||||
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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 '{}'
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Skill",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
# 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 -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
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
|
||||
TEST_DB_NAME: resolutionflow_test
|
||||
DB_APP_ROLE_PASSWORD: app_secret_ci
|
||||
|
||||
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
|
||||
|
||||
- name: Run Alembic migrations
|
||||
run: cd backend && alembic upgrade head
|
||||
|
||||
- name: Check tenant filter enforcement
|
||||
run: cd backend && python scripts/check_tenant_filters.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
# `-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()
|
||||
run: |
|
||||
cd backend
|
||||
python -c "
|
||||
import json
|
||||
with open('coverage.json') as f:
|
||||
data = json.load(f)
|
||||
total = data['totals']['percent_covered_display']
|
||||
print(f'Total coverage: {total}%')
|
||||
print()
|
||||
print('Module coverage:')
|
||||
for fname, fdata in sorted(data['files'].items()):
|
||||
pct = fdata['summary']['percent_covered_display']
|
||||
if float(pct) < 80:
|
||||
print(f' WARNING {fname}: {pct}%')
|
||||
else:
|
||||
print(f' OK {fname}: {pct}%')
|
||||
"
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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
|
||||
|
||||
- name: Lint
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Test with coverage
|
||||
run: cd frontend && npm run test:coverage
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
# 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:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
# 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 -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
PLAYWRIGHT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
PLAYWRIGHT_DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
PLAYWRIGHT_API_ORIGIN: http://127.0.0.1:8000
|
||||
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
|
||||
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: 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
|
||||
|
||||
- name: Run Playwright smoke tests
|
||||
run: cd frontend && npm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
frontend/playwright-report
|
||||
frontend/test-results
|
||||
if-no-files-found: ignore
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Push to GitHub
|
||||
run: |
|
||||
cd /tmp
|
||||
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
|
||||
cd repo
|
||||
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
|
||||
git push github --all --force
|
||||
git push github --tags --force
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Runner Probe
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
probe:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Runner labels and OS
|
||||
run: |
|
||||
echo "=== OS ==="
|
||||
uname -a
|
||||
cat /etc/os-release 2>/dev/null || true
|
||||
|
||||
- name: Python versions
|
||||
run: |
|
||||
echo "=== Python ==="
|
||||
which python3 && python3 --version || echo "python3 not found"
|
||||
which python && python --version || echo "python not found"
|
||||
ls /usr/bin/python* 2>/dev/null || true
|
||||
|
||||
- name: Node versions
|
||||
run: |
|
||||
echo "=== Node ==="
|
||||
which node && node --version || echo "node not found"
|
||||
which npm && npm --version || echo "npm not found"
|
||||
ls /usr/bin/node* 2>/dev/null || true
|
||||
ls ~/.nvm/versions/node/ 2>/dev/null || echo "no nvm versions"
|
||||
|
||||
- name: Docker
|
||||
run: |
|
||||
echo "=== Docker ==="
|
||||
which docker && docker --version || echo "docker not found"
|
||||
docker info 2>/dev/null | grep -E "Server Version|Operating System" || true
|
||||
|
||||
- name: User and home
|
||||
run: |
|
||||
echo "=== User ==="
|
||||
whoami
|
||||
echo "HOME=$HOME"
|
||||
echo "PATH=$PATH"
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -207,11 +207,7 @@ marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Claude Code (local config, agents, settings)
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/hooks/
|
||||
.claude/hooks/*
|
||||
!.claude/hooks/check-gstack.sh
|
||||
.claude/
|
||||
.agents/
|
||||
|
||||
# Database dumps
|
||||
@@ -242,6 +238,3 @@ package-lock.json
|
||||
# graphify knowledge graph outputs
|
||||
graphify-out/
|
||||
.graphify_python
|
||||
|
||||
# remember skill runtime state (hook logs, PIDs)
|
||||
.remember/
|
||||
|
||||
61
AGENTS.md
61
AGENTS.md
@@ -1,61 +0,0 @@
|
||||
# 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. If `python`/`npm` aren't on PATH, the host runs services in Docker — use the `docker exec resolutionflow_{backend,frontend} …` form documented in `.ai/PROJECT_CONTEXT.md` rather than installing toolchains.
|
||||
- **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.
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,55 +2,21 @@
|
||||
|
||||
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]
|
||||
|
||||
### Changed
|
||||
- **In-product User Guides rewrite** — replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories (Getting started, Working a pilot session, Closing out a session, Documentation & sharing, Authoring flows, Reusable assets, AI assistance, PSA integrations, Account & team admin, Analytics). Dropped three deprecated guides (Maintenance Flows, AI Assistant page, Flow Assist sparkle button — UI no longer exists). Renamed Step Library → Solutions Library to match canonical product terminology. Corrected sidebar entry-path references throughout (Dashboard → Home, All Flows → Flows, Sessions → History, Analytics → Data, etc.). Added `category` and optional `relatedSlugs` to the Guide schema; `GuidesHubPage` now renders category sections; `GuideDetailPage` shows a "Related guides" footer when set. Authored 14 net-new how-tos covering FlowPilot-era surfaces with no prior coverage: tasklane keyboard flow, what-we-know panel, ask-the-AI mid-session, pause-and-leave, resolve a session, record a suggested-fix outcome, escalate (Escalation Mode), post docs to a ConnectWise ticket, share a client update mid-session, build a script with Script Builder, open an AI-suggested flow, pin a flow, and invite a teammate. Fixed a long-standing rendering bug where `**bold**` markdown in `step.tip` rendered literally instead of bolded — the same regex replacement now runs on tips as on instructions. Killed the misleading "N sections" subtitle on guide cards (single-section how-tos make the count noise).
|
||||
|
||||
### Added
|
||||
- **TaskLane keyboard-first answer flow** (#158) — Enter submits and auto-advances to the next pending task; Shift+Enter inserts a newline; Esc cancels; after the last task, focus jumps to the Send Responses button so the engineer can fire the whole batch with one more keystroke. Mouse path also auto-advances. Subtle hint row (`⏎ submit · ⇧⏎ newline`) under each open input teaches the shortcut.
|
||||
- **Collapsible "What we know" section** (#158) — TaskLane's facts list is now a collapsible section with per-session memory in `sessionStorage`. Auto-collapses on first render at ≥5 facts so Questions and Diagnostic Checks stay above the fold; engineer's explicit toggle always wins.
|
||||
- **Escalation Mode wedge** (#155) — when an engineer escalates, the senior tech who claims the session lands on a magic-moment handoff-context screen with the structured briefing visible in seconds (no scrolling, no chat re-read). Live SSE pushes new arrivals to anyone watching the queue, atomic claim resolves race conditions, the queue auto-excludes the claimed session, the claiming user retains chat ownership for AI briefings, and a new analytics endpoint tracks post-claim time-to-first-action so you can see real minutes recovered (paired with a manual baseline — see CURRENT_TASK.md two-metric framing).
|
||||
- **Suggested-fix "Awaiting verification" outcome** (#156) — when a fix needs external confirmation (client power-cycle, AD replication, license sync) you can park it in `applied_pending` instead of forcing a worked / didn't / partial verdict. The new PendingBanner shows the parked status with worked / didn't / update reason / dismiss actions. The "Still checking" nudge records pending with a reason instead of just silencing. Page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending the same way it intercepts verifying/partial. Resolution notes and escalation packages frame the pending state honestly (provisional fix; leading hypothesis with what's being waited on).
|
||||
- Tree Templates + Import/Export marketplace (#66)
|
||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
|
||||
- **Tenant Isolation Phase 0** — multi-tenant data isolation (#132) with app-layer filtering helpers (`tenant_filter()`, `get_tenant_context`), cross-tenant access audit (analytics, categories, AI sessions, trees), UUID endpoint isolation with 404 responses for unauthorized access, ownership checks on all sensitive operations, and CI grep gate for missing tenant filters
|
||||
- **Tenant Isolation Phase 2** — PostgreSQL Row Level Security (RLS) on 11 session-related tables (ai_sessions, session_steps, session_tags, etc.), account_id NOT NULL enforcement on all write paths, Alembic migrations with dual-env support (Railway native vars + explicit DATABASE_URL_SYNC), RLS test coverage with cross-account isolation verification, migration CI/CD integration
|
||||
- **Tenant Isolation Phase 3** — RLS on audit_logs and tree_shares tables, cross-tenant session access for public shares (via get_admin_db), complete account_id propagation across PSA integration write paths, final RLS policy enforcement
|
||||
- **Tenant Isolation Phase 4** (#136) — RLS enforcement on all 31 remaining tables (users, trees, teams, integrations, scripts, categories, templates, surveys, etc.), BYPASSRLS session pattern for auth deps and background jobs, admin session factory for startup routines (service accounts, seed data), global table exclusions (platform_steps, template_trees, script_categories, accounts), RLS tests with complete cross-tenant isolation verification, proper tree_shares ownership checks using tree owner's account_id
|
||||
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
|
||||
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
|
||||
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
|
||||
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
|
||||
|
||||
### Changed
|
||||
- **Assistant Chat session screen — UX overhaul** (#158, "impeccable" pass) — removed the duplicate "Suggested checks" chip strip in favor of the TaskLane as the single source of truth; added an inline `Next steps · N pending` cue above the latest action-bearing AI bubble; consolidated the session header to two visible primary actions (Resolve + Escalate) plus a kebab for Context / New Ticket / Update Ticket / Pause; centered the messages column to `max-w-3xl` to match the composer; unified chat-bubble radii to `rounded-xl`; dropped every banned decoration (3px side stripes, gradient surfaces, accent borderTop, backdrop blur, pulse rings, bordered avatar boxes) for a single decoration channel per surface; unified 14 distinct text sizes into a 5-step scale (10/11/12/13/14px); split the ambiguous `MessageCircleQuestion` icon into `Pencil` (write affordance for question Answer CTA) and `HelpCircle` (universal help icon for the per-check explainer); audited and dropped redundant `font-sans` classes across the screen.
|
||||
- **Suggested-fix banner ↔ script panel are now linked** (#158) — collapsing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; dismissing the banner closes both surfaces. Recording any outcome on a fix (Dismiss, It worked, Didn't work, Mark partial, Waiting to verify) closes the script panel alongside the banner state transition.
|
||||
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
|
||||
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
|
||||
- **Account settings page** — audit fixes for improved consistency and usability
|
||||
@@ -61,14 +27,12 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
|
||||
|
||||
### Fixed
|
||||
- **`ParameterizationPreview` over-highlight on short parameter values** (#158) — the tokenizer matched highlight values via raw substring with no word-boundary check, so a single-char value like `"D"` (a drive letter) lit up every capital D in identifiers like `Get-ADUser`, `Add-Type`, `Disable-`. Added a word-boundary guard that's conditional on whether the value itself starts/ends with a word character, so values with leading/trailing punctuation (e.g. `"D:\\Folder"`) still match cleanly when adjacent to whitespace.
|
||||
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
|
||||
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
|
||||
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.
|
||||
- **Category tree counts** — cross-tenant row count leakage via tree_count field in GET `/categories/{id}`. Now scoped to requesting account.
|
||||
- **PSA retry ownership check** — retry-psa-push had no ownership validation (CRITICAL). Now validates user ownership before allowing retry.
|
||||
- **Task Lane save operation** — invalid task_lane_item UUIDs returned 403 revealing existence. Now returns 404 and uses query-level filtering.
|
||||
- **Phase 4 RLS enforcement** — fixed auth deps, user-mutation endpoints, background jobs, and lifespan routines to use BYPASSRLS sessions for reading/writing tenant-isolated tables; fixed seed scripts to use ADMIN_DATABASE_URL; bootstrap service account now initializes correctly with proper BYPASSRLS context
|
||||
- Dark text rendering on blue accent step-number badges across all flow types
|
||||
- Script Library tab ownership filter now preserved across category and search changes
|
||||
- Race conditions in script builder session creation and slug generation
|
||||
|
||||
650
CLAUDE.md
650
CLAUDE.md
@@ -1,74 +1,628 @@
|
||||
# CLAUDE.md — ResolutionFlow
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
You are Claude Code, the primary coding agent for ResolutionFlow. OpenAI Codex is the resume agent when you hit session or weekly limits.
|
||||
> **Last Updated:** April 6, 2026
|
||||
|
||||
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 AGENTS.md. If you edit one, edit the other.
|
||||
## Project Overview
|
||||
|
||||
## Shared protocol
|
||||
**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for MSP professionals**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes, and generate professional ticket documentation.
|
||||
|
||||
### Startup ritual (every session)
|
||||
**Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients.
|
||||
|
||||
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.
|
||||
**SaaS Context:** Multi-tenant design — teams represent MSP companies, trees shared within teams, tiered access (super_admin, team_admin, engineer, viewer).
|
||||
|
||||
### Handoff ritual (session end — limit hit, task complete, or user stop)
|
||||
### Branding
|
||||
|
||||
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.
|
||||
| Context | Name Used |
|
||||
|---------|-----------|
|
||||
| Repository / directory / database | `patherly` (internal name) |
|
||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
||||
|
||||
### Writing rules for .ai/ files
|
||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
||||
- **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned.
|
||||
- **Accent color:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9).
|
||||
- **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts
|
||||
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
|
||||
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB.
|
||||
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
|
||||
|
||||
- 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.
|
||||
**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows.
|
||||
|
||||
### Project principle
|
||||
## Implementation Principles
|
||||
|
||||
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||
- Prefer correct architecture over minimal diff
|
||||
- If two approaches exist, implement the one that scales, not the one that's faster to write
|
||||
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
|
||||
|
||||
## Claude-specific tooling
|
||||
---
|
||||
|
||||
### GitNexus code intelligence
|
||||
## Current State
|
||||
|
||||
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
|
||||
- **Phase:** Go-to-Market Validation (Pre-PMF)
|
||||
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
|
||||
- **Frontend:** Core features complete, Tree Editor functional
|
||||
- **Database:** PostgreSQL with Docker, 101 migrations
|
||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
||||
|
||||
| Tool | When |
|
||||
|---|---|
|
||||
| `gitnexus_query({query})` | Find code by concept when you don't know where to look |
|
||||
| `gitnexus_context({name})` | Callers/callees of a symbol before touching it |
|
||||
| `gitnexus_impact({target, direction})` | Blast radius before editing shared symbols |
|
||||
| `gitnexus_rename({symbol_name, new_name, dry_run: true})` | Safe multi-file rename |
|
||||
### What's In Progress
|
||||
|
||||
**Use for:** core shared symbols (`flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks), cross-file renames, unfamiliar bug traces, refactor safety. **Skip for:** new endpoints, isolated fixes, changes you can read in one file.
|
||||
- GTM validation: Shadow & Ship — founder dogfooding for 2 weeks, then 5 colleague pilot
|
||||
- Solutions Library spec written (`docs/plans/2026-03-23-solutions-library-design.md`), implementation post-pilot
|
||||
- Remaining open issues: #66 Templates + Import/Export, #60 Recurring Issue Detection, #58 Step Feedback Flag
|
||||
|
||||
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
|
||||
---
|
||||
|
||||
### gstack skills
|
||||
## Tech Stack
|
||||
|
||||
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
|
||||
### Backend
|
||||
|
||||
Available commands:
|
||||
- **Framework:** Python FastAPI
|
||||
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
||||
- **Migrations:** Alembic
|
||||
- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based)
|
||||
- **Validation:** Pydantic v2
|
||||
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
|
||||
|
||||
- **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`
|
||||
### Frontend
|
||||
|
||||
### Git trailer
|
||||
- **Framework:** React 19 + Vite + TypeScript
|
||||
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
|
||||
- **State:** Zustand (with immer + zundo for undo/redo)
|
||||
- **Routing:** React Router v7
|
||||
- **API Client:** Axios with token refresh interceptor
|
||||
- **Icons:** Lucide React
|
||||
|
||||
Every commit: `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`
|
||||
---
|
||||
|
||||
### Model aliases
|
||||
## Key Project Structure
|
||||
|
||||
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.
|
||||
```
|
||||
patherly/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry point
|
||||
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
|
||||
│ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
|
||||
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
|
||||
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
|
||||
│ │ ├── api/router.py # Route registration
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
||||
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
|
||||
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
|
||||
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
|
||||
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
|
||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
||||
│ └── tests/ # pytest integration tests
|
||||
├── 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/ # All page components
|
||||
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/ # TypeScript interfaces
|
||||
│ └── (Tailwind v4: CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
|
||||
├── CLAUDE.md # This file
|
||||
├── CURRENT-STATE.md # Detailed feature status
|
||||
├── LESSONS-LEARNED.md # (Deprecated — consolidated into CLAUDE.md)
|
||||
└── docs/plans/ # Design docs & implementation plans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
|
||||
```bash
|
||||
APP_NAME=ResolutionFlow
|
||||
DEBUG=true
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
REQUIRE_INVITE_CODE=true
|
||||
```
|
||||
|
||||
### Frontend (`frontend/.env.local` - optional)
|
||||
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise PSA Integration
|
||||
|
||||
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
|
||||
|
||||
### Best Practices Documentation
|
||||
|
||||
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
|
||||
|
||||
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
|
||||
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
|
||||
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
|
||||
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
|
||||
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
|
||||
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
|
||||
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
|
||||
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
|
||||
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
|
||||
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
|
||||
|
||||
### Reference Files (read in this order)
|
||||
|
||||
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
|
||||
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
|
||||
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
|
||||
|
||||
### Integration Architecture
|
||||
|
||||
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
|
||||
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
|
||||
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
|
||||
|
||||
### Key Implementation Rules
|
||||
|
||||
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
|
||||
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
|
||||
- All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
|
||||
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
|
||||
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
|
||||
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
|
||||
- Design for the Autotask integration following the same service layer pattern (future PSA)
|
||||
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
|
||||
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
```powershell
|
||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker start resolutionflow_postgres
|
||||
|
||||
# Backend (from backend/)
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# .\venv\Scripts\Activate # Windows
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Frontend (from frontend/)
|
||||
npm run dev
|
||||
|
||||
# Run tests (from backend/)
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# First time only: create test database
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||
|
||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
||||
cd frontend && npm run build
|
||||
|
||||
# Database migrations
|
||||
cd backend && alembic upgrade head
|
||||
alembic revision --autogenerate -m "Description"
|
||||
# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
|
||||
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
|
||||
|
||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
|
||||
# Seed data
|
||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||
|
||||
# CI/CD debugging
|
||||
gh run list --limit 5 # Recent CI runs
|
||||
gh run view <id> --log-failed # Failed job logs
|
||||
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
|
||||
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
|
||||
```
|
||||
|
||||
### URLs
|
||||
|
||||
- Frontend: <http://localhost:5173>
|
||||
- Backend API: <http://localhost:8000>
|
||||
- API Docs: <http://localhost:8000/api/docs>
|
||||
|
||||
### Test Users (seeded via `scripts/seed_test_users.py`)
|
||||
|
||||
- All share password: `TestPass123!`
|
||||
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
|
||||
|
||||
---
|
||||
|
||||
## Critical Lessons Learned
|
||||
|
||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
|
||||
|
||||
### Active Lessons (41+)
|
||||
|
||||
**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. Actions route to model tiers via `settings.get_model_for_action()`. 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:** Check the full data flow: 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: use 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_*` or `VITE_PUBLIC_*` env var must be added as `ARG` + `ENV` in `frontend/Dockerfile` for Railway deploys. Railway env vars are runtime-only unless explicitly passed through as Docker build args. 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()` — there is no intake form screen or "Start" button. Variables are filled inline during execution. Troubleshooting flows DO have a start screen with ticket/client fields. Don't write tests or UI that assume a Start button on procedural flows.
|
||||
|
||||
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
|
||||
|
||||
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
|
||||
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
|
||||
|
||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`.
|
||||
|
||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
|
||||
|
||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
|
||||
|
||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
|
||||
|
||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
|
||||
|
||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
|
||||
|
||||
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
|
||||
|
||||
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
|
||||
|
||||
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
|
||||
|
||||
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
|
||||
|
||||
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
|
||||
|
||||
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
|
||||
|
||||
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
|
||||
|
||||
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Not "Decision Tree Platform". This tagline appears on login, register, and the HTML `<title>`. The old "Decision Tree Platform" was internal jargon misaligned with user-facing branding.
|
||||
|
||||
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` (bottom-sheet on mobile, centered on desktop) and `max-w-full sm:max-w-lg` (full-width on mobile). The shared `Modal.tsx` does this correctly — custom modal implementations must follow the same pattern. See `PrepareSessionModal.tsx` for the fix pattern.
|
||||
|
||||
**80. TopBar search collapses to icon on mobile:** Full search bar (`hidden sm:block`) shows on desktop; magnifying glass icon button (`sm:hidden`) shows on mobile (<640px). Both open the same CommandPalette. Don't add `w-full` search bar without the mobile icon fallback.
|
||||
|
||||
**81. Never use `transition: all` in landing.css:** Specify exact properties: `transition: background 0.3s, border-color 0.3s, box-shadow 0.3s, transform 0.3s, opacity 0.3s`. `transition: all` animates layout properties and causes jank.
|
||||
|
||||
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
|
||||
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
|
||||
|
||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
||||
|
||||
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
||||
|
||||
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
|
||||
|
||||
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
|
||||
|
||||
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
|
||||
|
||||
*(Lessons 89–91 were retracted.)*
|
||||
|
||||
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
|
||||
|
||||
**93. FlowPilot actions live in the page header, not a bottom bar:** `FlowPilotSessionPage` renders Resolve/Escalate/Share Update in the header bar. Desktop: inline buttons + `⋯` overflow (Pause/Close). Mobile: single `⋯` menu. The bottom only has the message input. `FlowPilotActionBar` component still exists but is no longer used in the main session flow.
|
||||
|
||||
**94. Frontend chat uses unified_chat_service, not assistant_chat_service:** `AssistantChatPage` calls `/ai-sessions/{id}/chat` → `unified_chat_service.py`. The old `assistant_chat_service` endpoints were removed (only retention settings remain at `/assistant/retention`). When tracing chat features, start from `aiSessionsApi.sendChatMessage` → `ai_sessions.py` → `unified_chat_service.py`. Never wire chat features into `assistant_chat.py`.
|
||||
|
||||
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
|
||||
|
||||
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
|
||||
|
||||
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
|
||||
|
||||
**98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`.
|
||||
|
||||
**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components.
|
||||
|
||||
**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements.
|
||||
|
||||
**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active.
|
||||
|
||||
**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message.
|
||||
|
||||
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
|
||||
|
||||
**104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
|
||||
|
||||
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
|
||||
|
||||
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
|
||||
|
||||
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
|
||||
|
||||
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
|
||||
|
||||
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
||||
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
|
||||
- **Backend deps:** `get_current_active_user(user, db)` (any active + auto-downgrades expired trials), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
|
||||
- **Never use** `role == "admin"` — use `is_super_admin` instead
|
||||
- **Frontend:** `usePermissions()` hook for all permission checks
|
||||
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
|
||||
|
||||
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
|
||||
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
|
||||
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
|
||||
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
||||
- **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
|
||||
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
|
||||
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
|
||||
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
|
||||
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
|
||||
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
|
||||
|
||||
---
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
|
||||
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
|
||||
- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
|
||||
- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
|
||||
- **Session sharing:** `ShareSessionModal` manages share links, `SharedSessionPage` renders public/account views. Helper utils in `lib/sessionShare.ts`. Share URLs use `/shared/sessions/:token`.
|
||||
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
|
||||
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
|
||||
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
|
||||
- **Dashboard cockpit:** `QuickStartPage` is the copilot-first launchpad. Greeting + "What are you troubleshooting?" + ChatGPT-style `StartSessionInput` (auto-growing textarea, paste images, drag-drop files, attach button, paste logs, suggestion chips). Below: `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible "Dashboard" section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
|
||||
- **Sidebar sections:** Amber "New Session" button → Home → RESOLVE (History) → KNOWLEDGE (Flows with Solutions Library sub-item, Scripts) → INSIGHTS (Data). Footer: Account, Pin/Unpin. No help/guides/feedback in sidebar — accessible via TopBar.
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
||||
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head`
|
||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Python
|
||||
|
||||
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
||||
|
||||
### Git
|
||||
|
||||
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||
- Large features: commit per phase with `npm run build` validation
|
||||
|
||||
### After Completing Work
|
||||
|
||||
When a feature, fix, or significant piece of work is finished and merged/committed:
|
||||
|
||||
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
|
||||
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
|
||||
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
|
||||
4. **Update `CLAUDE.md`** if the work introduced new patterns, lessons learned, or changed project structure
|
||||
|
||||
---
|
||||
|
||||
## gstack (Browser & Workflow Skills)
|
||||
|
||||
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
|
||||
|
||||
**Available skills:**
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
|
||||
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
|
||||
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
|
||||
| `/plan-design-review` | Design plan review (UI/UX critique) |
|
||||
| `/design-consultation` | Create a design system / DESIGN.md |
|
||||
| `/review` | Pre-landing PR code review |
|
||||
| `/ship` | Ship workflow (tests, review, PR creation) |
|
||||
| `/browse` | Headless browser for QA testing and site dogfooding |
|
||||
| `/qa` | Systematic QA testing + auto-fix bugs found |
|
||||
| `/qa-only` | QA report only (no fixes) |
|
||||
| `/design-review` | Visual QA — find and fix design inconsistencies |
|
||||
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
|
||||
| `/retro` | Weekly engineering retrospective |
|
||||
| `/investigate` | Systematic debugging with root cause analysis |
|
||||
| `/document-release` | Post-ship documentation updates |
|
||||
| `/codex` | Second opinion via OpenAI Codex CLI |
|
||||
| `/careful` | Safety guardrails for destructive commands |
|
||||
| `/freeze` | Restrict edits to a specific directory |
|
||||
| `/guard` | Full safety mode (careful + freeze) |
|
||||
| `/unfreeze` | Remove edit restrictions |
|
||||
| `/gstack-upgrade` | Upgrade gstack to latest version |
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Railway)
|
||||
|
||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
||||
- Auto-deploys on push to `main`
|
||||
- PR environments auto-created (need manual domain generation in Railway dashboard)
|
||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
||||
- Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
|
||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
|
||||
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| API Docs | <http://localhost:8000/api/docs> |
|
||||
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||
| GitHub Issues | `gh issue list --state open` |
|
||||
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
|
||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **resolutionflow** (16703 symbols, 35922 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
|
||||
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
|
||||
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
||||
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||
> **Last Updated:** May 1, 2026
|
||||
> **Last Updated:** April 12, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -10,14 +10,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Recently shipped (post-0.1.0.0)
|
||||
|
||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
|
||||
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
|
||||
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
|
||||
|
||||
---
|
||||
|
||||
## What's Complete
|
||||
|
||||
### Core Platform
|
||||
|
||||
783
DEV-ENV.md
783
DEV-ENV.md
@@ -1,671 +1,262 @@
|
||||
# ResolutionFlow — Dev Environment Setup & Operations Guide
|
||||
# ResolutionFlow Dev Environment Setup & Operations Guide
|
||||
|
||||
> **Scope:** Stand up a working ResolutionFlow dev environment from scratch on any Linux host (VPS, on-prem Proxmox LXC/VM, bare metal). Self-contained — do not read another doc to get the dev stack running.
|
||||
> **Last rewritten:** April 2026, post-Hostinger-VPS deprecation, ahead of Proxmox migration.
|
||||
> **Audience:** You (returning to the project), a teammate, or a fresh Claude Code session.
|
||||
## Server Overview
|
||||
|
||||
If you're picking up mid-migration and need to know what code state is on the current branch, read `docs/FlowAssist_Migration/MIGRATION-HANDOFF.md` first.
|
||||
- **Provider:** Hostinger KVM VPS (srv1522117)
|
||||
- **IP Address:** 46.202.92.250
|
||||
- **OS:** Ubuntu 24.04 LTS
|
||||
- **CPU:** 2 vCPU cores
|
||||
- **RAM:** 8GB
|
||||
- **Disk:** 100GB NVMe SSD
|
||||
- **Swap:** 4GB (`/swapfile`, swappiness=10)
|
||||
|
||||
---
|
||||
## Architecture
|
||||
|
||||
## 1. What this project needs, regardless of host
|
||||
All services run as Docker containers on the host, managed via SSH or from the VS Code Server integrated terminal.
|
||||
|
||||
These are non-negotiable. If your host can't provide them, fix that before anything else.
|
||||
```
|
||||
Host (root@srv1522117)
|
||||
├── Traefik → reverse proxy + auto SSL (Let's Encrypt)
|
||||
├── VS Code Server → browser IDE at https://code.resolutionflow.com
|
||||
└── ResolutionFlow Stack
|
||||
├── resolutionflow_frontend → Vite/React on port 5173
|
||||
├── resolutionflow_backend → FastAPI/Uvicorn on port 8000
|
||||
└── resolutionflow_postgres → PostgreSQL 16 + pgvector on port 5432
|
||||
```
|
||||
|
||||
| Component | Required version | Notes |
|
||||
|---|---|---|
|
||||
| **Linux** | any mainstream distro | Ubuntu 22.04+ / Debian 12+ tested; Alpine fine for containers |
|
||||
| **Python** | 3.11+ | Backend and migrations |
|
||||
| **Node.js** | 20.19+ | Vite 7 fails on older versions — CLAUDE.md Lesson 63 |
|
||||
| **PostgreSQL** | 16 | `gen_random_uuid()` + `jsonb` + RLS are all leaned on |
|
||||
| **Docker + Docker Compose** | recent | Only if you are running Postgres and/or backend as containers |
|
||||
| **Git** | recent | |
|
||||
## Access URLs
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
| Tool | Why |
|
||||
| Service | URL |
|
||||
|---|---|
|
||||
| **code-server** | Browser-based VS Code; how this project has historically been edited |
|
||||
| **`gh` CLI** | Mirror repo is on GitHub via Gitea; `gh` reads issues and PRs |
|
||||
| **bun** | Required for the gstack `/browse` + `/qa` skills (CLAUDE.md Lesson 82) |
|
||||
| **`npx gitnexus analyze`** | Code-graph for Phase 2+ work that touches `unified_chat_service` |
|
||||
| **Claude Code CLI** | If you want to run Claude Code locally on the host |
|
||||
| VS Code Server | https://code.resolutionflow.com |
|
||||
| Frontend (dev) | http://46.202.92.250:5173 |
|
||||
| Backend API | http://46.202.92.250:8000 |
|
||||
| API Docs | http://46.202.92.250:8000/docs |
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectural shape
|
||||
|
||||
The project is three services plus your editor. Keep these facts in mind regardless of topology:
|
||||
## Docker Layout
|
||||
|
||||
```
|
||||
Your browser
|
||||
├─► code-server (editor, optional — usually port 8080 or behind TLS)
|
||||
├─► frontend (Vite) (dev server, port 5173)
|
||||
└─► backend (FastAPI) (dev server, port 8000)
|
||||
│
|
||||
└─► PostgreSQL (port 5432)
|
||||
/docker/
|
||||
├── traefik/
|
||||
│ ├── docker-compose.yml → Traefik reverse proxy
|
||||
│ └── .env → ACME_EMAIL for Let's Encrypt
|
||||
└── vscode/
|
||||
├── docker-compose.yml → VS Code Server
|
||||
└── .env → CODE_PASSWORD
|
||||
```
|
||||
|
||||
**The frontend calls the backend by URL at runtime.** The frontend does not proxy through the backend. Whatever URL your browser uses to reach the backend is what `VITE_API_URL` must be set to, **baked in at build time**. Changing `VITE_API_URL` requires rebuilding the frontend.
|
||||
|
||||
**The backend calls the database by URL at runtime.** The URL depends on where Postgres is relative to the backend — Docker service name if both are in the same compose network, `localhost` if Postgres is native on the same host, or a DNS name if they're in separate containers/VMs.
|
||||
|
||||
**CORS is configured explicitly.** The backend's `CORS_ORIGINS` list must include every origin your browser will use to reach the frontend. A missing origin shows up as failed preflight requests.
|
||||
|
||||
---
|
||||
|
||||
## 3. Topology choices — pick one before you start
|
||||
|
||||
The project is agnostic to topology, but each shape has different setup steps.
|
||||
|
||||
### Option A — all-in-one LXC/VM/host (simplest)
|
||||
|
||||
Postgres, backend, and frontend all run on one Linux host. code-server runs on the same host or a sibling. No Docker required. Best for a single-developer Proxmox LXC.
|
||||
|
||||
### Option B — Docker Compose on one host
|
||||
|
||||
Postgres, backend, and frontend run as Docker containers on one host. code-server runs outside the compose network (on the host or in another container). This is how the old Hostinger VPS was configured. Best if you want reproducible container images.
|
||||
|
||||
### Option C — split services across containers/VMs
|
||||
|
||||
Postgres in one container/VM, backend and frontend in another, code-server in a third. Most complex; requires explicit networking between them. Use only if you have a specific reason.
|
||||
|
||||
**Pick one and stick with it for the entire setup.** Mixing Options A and B halfway through is where setup runs off the rails.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-host configuration
|
||||
|
||||
These values are specific to your host. Fill them in once and reference them by name throughout the rest of the doc.
|
||||
|
||||
Project lives inside the VS Code Server Docker volume:
|
||||
```
|
||||
DEV_HOST = <hostname or IP your browser uses, e.g. dev.internal, 10.0.0.42>
|
||||
DEV_HOST_SCHEME = <http or https; http is fine for internal dev, https if behind a TLS proxy>
|
||||
FRONTEND_PORT = 5173
|
||||
BACKEND_PORT = 8000
|
||||
POSTGRES_PORT = 5433 # host-side port. 5433 is the recommended default on any shared host to avoid collision with a host-level Postgres. The container's internal port stays 5432.
|
||||
POSTGRES_DB_NAME = resolutionflow
|
||||
POSTGRES_USER = postgres
|
||||
POSTGRES_PASSWORD = <local-dev-password; anything, this is not prod>
|
||||
SECRET_KEY = <openssl rand -hex 32 — generate fresh per host, do not reuse>
|
||||
ANTHROPIC_API_KEY = <from https://console.anthropic.com>
|
||||
GOOGLE_AI_API_KEY = <optional, only if using Gemini as a fallback>
|
||||
/var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow/
|
||||
```
|
||||
|
||||
Store these somewhere you can copy from during setup. Do not commit them.
|
||||
## VS Code Server
|
||||
|
||||
> **Naming note:** the canonical database name is `resolutionflow`. If you see `patherly` in a config file, that's drift from an earlier rename and is being swept in a separate commit — use `resolutionflow`. CLAUDE.md tracks the live-code files that still reference `patherly`.
|
||||
- **Container user:** `coder` (UID 1000)
|
||||
- **Home directory:** `/home/coder`
|
||||
- **Project location:** `/home/coder/resolutionflow`
|
||||
- **Host volume path:** `/var/lib/docker/volumes/vscode_vscode-data/_data`
|
||||
- **Access URL:** `https://code.resolutionflow.com`
|
||||
- **HTTPS:** Auto-provisioned via Traefik + Let's Encrypt
|
||||
|
||||
---
|
||||
### Compose File Location
|
||||
`/docker/vscode/docker-compose.yml`
|
||||
|
||||
## 5. Setup procedure
|
||||
## Traefik
|
||||
|
||||
Run these in order. Stop at the first failure and investigate.
|
||||
Handles reverse proxying and automatic SSL for all services. HTTP automatically redirects to HTTPS.
|
||||
|
||||
### 5.1 Install system dependencies
|
||||
### Adding A New Service Behind Traefik
|
||||
|
||||
Add these labels to any new Docker service:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.<n>.rule=Host(`subdomain.resolutionflow.com`)"
|
||||
- "traefik.http.routers.<n>.entrypoints=websecure"
|
||||
- "traefik.http.routers.<n>.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.<n>.loadbalancer.server.port=<port>"
|
||||
```
|
||||
|
||||
Also create an A record in DNS pointing the subdomain to `46.202.92.250`.
|
||||
|
||||
## ResolutionFlow Dev Stack
|
||||
|
||||
### Important: No Docker Inside VS Code Container
|
||||
|
||||
The VS Code Server container does NOT have Docker. All `docker compose` commands must be run via SSH as root on the host.
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `.env` | Root — Docker Compose interpolation (`SECRET_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_AI_API_KEY`, `POSTGRES_PORT`) |
|
||||
| `backend/.env` | Backend source of truth — all FastAPI settings, API keys, DB URLs, CORS |
|
||||
| `frontend/.env` | Frontend — `VITE_API_URL` pointing to backend |
|
||||
|
||||
### Critical Remote Access Config
|
||||
|
||||
**`frontend/.env`:**
|
||||
```
|
||||
VITE_API_URL=http://46.202.92.250:8000
|
||||
```
|
||||
|
||||
**`backend/.env`:**
|
||||
```
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
|
||||
FRONTEND_URL=http://46.202.92.250:5173
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
|
||||
```
|
||||
|
||||
Note: `DATABASE_URL` uses `@db:5432` (Docker service name), not `@localhost`.
|
||||
|
||||
**`docker-compose.dev.yml`:**
|
||||
```yaml
|
||||
- VITE_API_URL=http://46.202.92.250:8000
|
||||
```
|
||||
|
||||
### Starting the Dev Environment
|
||||
|
||||
SSH into host as root:
|
||||
|
||||
```bash
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
|
||||
nvm install 20
|
||||
nvm alias default 20
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
For Option B (Docker Compose), also:
|
||||
### Running Migrations (Fresh Database)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER # log out and back in for this to take effect
|
||||
```
|
||||
|
||||
### 5.2 Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://gitea.resolutionflow.com/chihlasm/resolutionflow.git
|
||||
# or the GitHub mirror:
|
||||
# git clone https://github.com/chihlasm/resolutionflow.git
|
||||
cd resolutionflow
|
||||
|
||||
# Check out the working branch if you're continuing mid-migration.
|
||||
git fetch origin
|
||||
git checkout feat/flowpilot-migration
|
||||
```
|
||||
|
||||
### 5.3 Start PostgreSQL
|
||||
|
||||
**Option A (native Postgres on the host):**
|
||||
|
||||
```bash
|
||||
sudo apt install -y postgresql-16
|
||||
sudo -u postgres psql -c "CREATE DATABASE resolutionflow;"
|
||||
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
|
||||
# Adjust pg_hba.conf if you need non-local connections.
|
||||
```
|
||||
|
||||
**Option B (Postgres via Docker Compose):** The repo has a `docker-compose.dev.yml` at the root. Check its Postgres service for the container name, port mapping, and volume. The local compose defaults use container name `resolutionflow_postgres`, database `resolutionflow`, and host-side port `5433` (mapped to the container's internal `5432`) — see CLAUDE.md Lesson 65. The host-side `5433` is the recommended default on any shared host: it keeps the port free for a host-level Postgres if you ever need one. The compose file also defines explicit `command:` directives on both `backend` and `frontend` to force `--host 0.0.0.0`, and expects the caller to pass `REPO_ROOT` (see 5.4) for bind-mount resolution. Confirm what the compose file actually says on your branch before trusting these values.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d db
|
||||
docker compose -f docker-compose.dev.yml logs db # wait for "ready to accept connections"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
# From the host (Option A) or the backend container/LXC (Option B):
|
||||
psql -h <db-host> -p <POSTGRES_PORT> -U postgres -d resolutionflow -c "SELECT now();"
|
||||
```
|
||||
|
||||
### 5.4 Write the `.env` files
|
||||
|
||||
The repo expects three env files. Create each one:
|
||||
|
||||
**`backend/.env`** — backend source of truth:
|
||||
|
||||
```bash
|
||||
APP_NAME=ResolutionFlow
|
||||
DEBUG=true
|
||||
|
||||
# DB URLs — `<db-host>` is `localhost` for Option A, the Docker service name
|
||||
# (e.g. `db`) for Option B, or the DB container/VM hostname for Option C.
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||
|
||||
# Auth
|
||||
SECRET_KEY=<SECRET_KEY>
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
REQUIRE_INVITE_CODE=true
|
||||
|
||||
# AI providers
|
||||
AI_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||
|
||||
# FlowPilot MCP telemetry — leave on so the Phase 0.5 baseline data keeps accruing
|
||||
ENABLE_MCP_MICROSOFT_LEARN=true
|
||||
|
||||
# CORS + frontend URL
|
||||
FRONTEND_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>
|
||||
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>"]
|
||||
```
|
||||
|
||||
**`frontend/.env.local`** — frontend build-time config:
|
||||
|
||||
```bash
|
||||
VITE_API_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>
|
||||
```
|
||||
|
||||
Optional PostHog (CLAUDE.md Lesson 64 — enables product analytics locally):
|
||||
|
||||
```bash
|
||||
VITE_PUBLIC_POSTHOG_KEY=<from PostHog project settings>
|
||||
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
```
|
||||
|
||||
**Repo root `.env`** — only needed for Option B (Docker Compose interpolation):
|
||||
|
||||
```bash
|
||||
SECRET_KEY=<SECRET_KEY>
|
||||
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||
POSTGRES_PORT=<POSTGRES_PORT>
|
||||
# Absolute host-side path to the repo root. REQUIRED whenever docker-compose is
|
||||
# invoked from inside a container (e.g. a code-server container with the host
|
||||
# Docker socket mounted in). Without it, the bind mounts in
|
||||
# docker-compose.dev.yml (`${REPO_ROOT}/backend:/app`, `${REPO_ROOT}/frontend:/app`)
|
||||
# resolve against the CLI's CWD — a path the host daemon cannot see — and
|
||||
# Docker silently creates empty directories there instead of mounting the code.
|
||||
# If you run docker compose directly on the host shell, you can set this to `.`
|
||||
# or the absolute path of the repo; being explicit is safer either way.
|
||||
REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
```
|
||||
|
||||
> **Never commit any `.env` file.** The `.gitignore` already covers this.
|
||||
|
||||
### 5.5 Run the backend setup
|
||||
|
||||
**Option A (native):**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Migrate the DB to head.
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Option B (Docker):**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d backend
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
```
|
||||
|
||||
**Expected alembic head** (as of `feat/flowpilot-migration`): `f07010f17b01`. If `alembic current` shows anything else after `upgrade head`, something has gone wrong — stop and investigate.
|
||||
|
||||
### 5.6 Seed test users
|
||||
### Seeding Test Users
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
python -m scripts.seed_test_users
|
||||
|
||||
# Option B
|
||||
docker exec resolutionflow_backend python -m scripts.seed_test_users
|
||||
```
|
||||
|
||||
Test users (all share password `TestPass123!`):
|
||||
Test accounts (password: `TestPass123!`):
|
||||
|
||||
| Email | Role |
|
||||
|---|---|
|
||||
| `admin@resolutionflow.example.com` | super admin |
|
||||
| `teamadmin@resolutionflow.example.com` | team admin |
|
||||
| `engineer@resolutionflow.example.com` | engineer |
|
||||
| `pro@resolutionflow.example.com` | solo pro |
|
||||
| Email | Role | Plan |
|
||||
|---|---|---|
|
||||
| admin@resolutionflow.example.com | Owner | Team |
|
||||
| pro@resolutionflow.example.com | Owner | Pro |
|
||||
| teamadmin@resolutionflow.example.com | Owner | Team |
|
||||
| engineer@resolutionflow.example.com | Engineer | Shared |
|
||||
|
||||
### 5.7 Run the backend
|
||||
|
||||
**Option A:**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
**Option B:** Already running from `docker compose up -d backend`. Tail logs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
```
|
||||
|
||||
**Verify:** `curl <DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>/api/docs` — OpenAPI docs page loads.
|
||||
|
||||
### 5.8 Run the frontend
|
||||
|
||||
**Option A:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --host 0.0.0.0 --port 5173
|
||||
```
|
||||
|
||||
**Option B:**
|
||||
### Rebuilding After Config Changes
|
||||
|
||||
**Frontend** (Vite bakes env vars at build time — requires rebuild):
|
||||
```bash
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml up -d --build frontend
|
||||
```
|
||||
|
||||
**Verify:** Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>` in your browser. Log in with one of the test users. Navigate to `/pilot` — the FlowPilot session page should render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification — proof the env actually works
|
||||
|
||||
Run these after setup. Every item has a concrete expected outcome.
|
||||
|
||||
### 6.1 Database schema is at the right version
|
||||
|
||||
**Backend** (restart only):
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate && alembic current
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic current
|
||||
```
|
||||
|
||||
Expected: `f07010f17b01 (head)` on the `feat/flowpilot-migration` branch. On `main`, expected: `074 (head)`.
|
||||
|
||||
### 6.2 Alembic reversibility
|
||||
|
||||
```bash
|
||||
alembic downgrade -1 # should complete cleanly
|
||||
alembic upgrade head # should return to f07010f17b01
|
||||
```
|
||||
|
||||
If either step fails, the migration has a bug and Phase 2 cannot start.
|
||||
|
||||
### 6.3 Prompt-cache hit verification (the deferred Phase 0 TODO)
|
||||
|
||||
`backend/app/core/ai_provider.py` module docstring has a `TODO(phase0-verify)` note describing this. Procedure:
|
||||
|
||||
1. Confirm `AI_PROVIDER=anthropic` and `ANTHROPIC_API_KEY` is set in `backend/.env`.
|
||||
2. Start the backend with log level INFO or lower.
|
||||
3. In the UI, open `/pilot` and send a chat message. Wait a few seconds for the response.
|
||||
4. Send a second chat message in the same session, within 5 minutes of the first.
|
||||
5. In backend logs, grep for lines containing `anthropic.cache`:
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
grep 'anthropic.cache' <log-path>
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml logs backend | grep 'anthropic.cache'
|
||||
```
|
||||
|
||||
6. Expected: two `anthropic.cache` log events. First has `cache_creation_input_tokens > 0`. Second has `cache_read_input_tokens > 0`.
|
||||
7. If the second shows zero reads, inspect the prompt prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding with any Phase 2 work.
|
||||
|
||||
### 6.4 Frontend build is TypeScript-clean
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npx tsc -b # no errors
|
||||
npm run build # no errors
|
||||
```
|
||||
|
||||
CLAUDE.md Lesson 105 notes that `npm run build` may fail with an `EACCES` on `dist/` inside code-server — that is a Docker filesystem permission issue, not a real build error. Use `npx tsc -b` to verify TypeScript cleanliness in that case.
|
||||
|
||||
### 6.5 `/assistant` → `/pilot` redirect
|
||||
|
||||
Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>/assistant/<some-real-session-id>` in the browser. Expected: URL changes to `/pilot/<that-id>`; the FlowPilot session page renders. Bare `/assistant` redirects to bare `/pilot`.
|
||||
|
||||
### 6.6 Dispatcher de-branching
|
||||
|
||||
Navigate to the dashboard. Click a session in `ActiveFlowPilotSessions` or `RecentFlowPilotSessions`. Expected: routes to `/pilot/:id` regardless of the session's `session_type` value. (Check the browser URL bar.)
|
||||
|
||||
### 6.7 CORS
|
||||
|
||||
Open the browser DevTools Network tab, navigate to any backend-hitting page. Expected: no CORS errors. If you see "blocked by CORS policy," the missing origin needs adding to `backend/.env`'s `CORS_ORIGINS`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Runbook
|
||||
|
||||
Day-to-day commands after setup is complete.
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
# backend — Ctrl-C and re-run uvicorn
|
||||
# frontend — Ctrl-C and re-run npm run dev
|
||||
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml restart backend
|
||||
docker compose -f docker-compose.dev.yml up -d --build frontend # rebuild required if VITE_* changed
|
||||
docker compose -f docker-compose.dev.yml down && docker compose -f docker-compose.dev.yml up -d # full restart
|
||||
```
|
||||
|
||||
### Apply a new migration
|
||||
**Full restart:**
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
## Installed Tools (Inside VS Code Server Container)
|
||||
|
||||
Installed in `/home/coder` — persists via Docker volume:
|
||||
|
||||
- **nvm** — Node version manager
|
||||
- **Node.js 20.x** — via nvm, default alias set
|
||||
- **npm** — latest
|
||||
- **GitHub CLI (gh)** — authenticated via personal access token
|
||||
- **Claude Code CLI** — `@anthropic-ai/claude-code` (global npm)
|
||||
|
||||
### Permanent Tool Installs
|
||||
|
||||
Tools installed via `apt` inside the container do NOT survive container rebuilds. To add permanently, modify the VS Code Server Docker image and rebuild.
|
||||
|
||||
Temporary (session only):
|
||||
```bash
|
||||
sudo apt update && sudo apt install -y <tool>
|
||||
```
|
||||
|
||||
## SSH Access
|
||||
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate && alembic upgrade head
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
ssh root@46.202.92.250
|
||||
```
|
||||
|
||||
### Create a new migration
|
||||
Key auth configured via `~/.ssh/authorized_keys` on host.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
### Check all running containers
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
alembic revision -m "short description" # manual, preferred per CLAUDE.md Lesson 77
|
||||
# OR
|
||||
alembic revision --autogenerate -m "description" # pulls in drift; review carefully
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
```
|
||||
|
||||
Never pass `--rev-id` — let Alembic generate the hex hash.
|
||||
|
||||
### Inspect the database
|
||||
|
||||
### View container logs
|
||||
```bash
|
||||
# Option A (native Postgres)
|
||||
psql -h localhost -p 5432 -U postgres -d resolutionflow
|
||||
|
||||
# Option B (Docker)
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
docker logs <container_name> --tail 30 -f
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
### Restart VS Code Server
|
||||
```bash
|
||||
# Option A
|
||||
cd backend && source venv/bin/activate
|
||||
pytest --override-ini="addopts="
|
||||
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml run --rm backend pytest --override-ini="addopts="
|
||||
cd /docker/vscode && docker compose restart
|
||||
```
|
||||
|
||||
First time only, create the test database:
|
||||
|
||||
### Restart Traefik
|
||||
```bash
|
||||
# Option A
|
||||
sudo -u postgres psql -c "CREATE DATABASE resolutionflow_test;"
|
||||
|
||||
# Option B
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||
cd /docker/traefik && docker compose restart
|
||||
```
|
||||
|
||||
### View backend logs
|
||||
|
||||
### Restart dev stack
|
||||
```bash
|
||||
# Option A: wherever you ran uvicorn
|
||||
# Option B
|
||||
docker compose -f docker-compose.dev.yml logs -f --tail=100 backend
|
||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Structured events to grep for:
|
||||
- `anthropic.cache` — prompt-cache hit/creation telemetry (Phase 0.1)
|
||||
- `mcp.turn` — per-turn MCP availability/invocation (Phase 0.5)
|
||||
- `mcp.fallback` — MCP silent-retry fallback fired (Phase 0.5)
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
### CORS errors in the browser
|
||||
|
||||
The backend did not accept the origin your browser used. Check `backend/.env`'s `CORS_ORIGINS` — it must include the exact scheme + host + port the browser sent. Restart the backend after editing.
|
||||
|
||||
### `VITE_API_URL` points at the wrong place
|
||||
|
||||
The frontend was built with a stale value. Rebuild the frontend. Option B: `docker compose up -d --build frontend`. Option A: restart `npm run dev`.
|
||||
|
||||
### `alembic upgrade head` fails with "target database is not up to date"
|
||||
|
||||
Your DB migration chain is out of sync with the code. On a dev box, the safe recovery is to drop the DB and re-migrate from scratch:
|
||||
|
||||
### Check swap
|
||||
```bash
|
||||
# Option A
|
||||
sudo -u postgres psql -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||
cd backend && source venv/bin/activate && alembic upgrade head
|
||||
|
||||
# Option B
|
||||
docker exec resolutionflow_postgres psql -U postgres -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||
free -h && swapon --show
|
||||
```
|
||||
|
||||
Only do this on a dev box — it destroys all local data.
|
||||
|
||||
### `alembic heads` shows more than one head
|
||||
|
||||
Only on a local branch that has diverged from `origin/main`. Production `main` has a single head. If this happens on a fresh clone, one of your local migration files has the wrong `down_revision`. Inspect each file's `down_revision` and reconnect the chain.
|
||||
|
||||
### Frontend build fails with "EACCES: permission denied" on `dist/`
|
||||
|
||||
Filesystem permission issue inside the code-server container (CLAUDE.md Lesson 105). TypeScript compilation itself completes — use `npx tsc -b` to verify cleanliness without needing to write to `dist/`.
|
||||
|
||||
### Backend/frontend containers start but `/app` is empty (no code mounted)
|
||||
|
||||
Almost always a `REPO_ROOT` problem. `docker-compose.dev.yml` uses `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app` bind mounts. If `REPO_ROOT` is unset, or set to a path that doesn't exist *on the Docker host* (not inside the code-server container), Docker silently creates an empty directory at that path and mounts it — the containers come up but have no source code. Symptom: backend returns import errors, or frontend serves a default Vite page. Fix: set `REPO_ROOT` in the repo-root `.env` to the absolute host-side path to the repo, then `docker compose down && docker compose up -d`. See 5.4 for the full note. This matters specifically when `docker compose` is invoked from inside a container (e.g. code-server with the host Docker socket mounted) — the CLI's CWD is container-local but the daemon resolves paths against the host filesystem.
|
||||
|
||||
### Frontend shows "Blocked request. This host is not allowed" in the browser
|
||||
|
||||
Vite 5+ ships DNS-rebinding protection that rejects any `Host:` header not in `server.allowedHosts`. The browser's hostname must be in that list. Edit `frontend/vite.config.ts` — the `server.allowedHosts` array should include every hostname you reach the dev server from (e.g. `'docker-01'`, `'localhost'`, `.ts.net` as a wildcard for Tailscale MagicDNS). Restart the Vite dev server (for Option B: `docker compose restart frontend`). This is unrelated to CORS — Vite blocks the request before any app code runs.
|
||||
|
||||
### `docker` command not found inside code-server
|
||||
|
||||
If your code-server is itself inside a container, Docker is probably not exposed to it. CLAUDE.md Lesson 103 was written for this case on the old VPS. On Proxmox, the fix depends on topology — either SSH to the host to run Docker commands, or mount the host's Docker socket into the code-server container.
|
||||
|
||||
### Backend returns 500 with `InsufficientPrivilegeError: new row violates row-level security policy`
|
||||
|
||||
RLS is enabled on a table your code wrote to without the right `account_id`. CLAUDE.md Lessons 107, 108, 110 cover this family of bugs. The fix is always at the service layer: make sure every model creation passes `account_id=` explicitly, and that startup routines that touch tenant-isolated tables use `_admin_session_factory()` rather than `get_db()`.
|
||||
|
||||
### Anthropic cache reads are zero on the second turn
|
||||
|
||||
Something in the cached prefix is changing between turns. Inspect the system-block list and the first N history messages for timestamps, `datetime.now()`, unsorted dict keys in JSON prompts, or varying tool-list order. The `anthropic.cache` telemetry shows exactly how many tokens were read vs created — use it to narrow down the invalidator.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security posture for dev environments
|
||||
|
||||
This doc is about dev, not production. But:
|
||||
|
||||
- Never commit `.env` files. The `.gitignore` covers this.
|
||||
- `SECRET_KEY` should be generated per-host, not reused across environments.
|
||||
- `ANTHROPIC_API_KEY` is billable — rotate if leaked into logs or chat.
|
||||
- Postgres on a dev host should not be exposed to the internet. Bind it to `127.0.0.1` or to a private network interface only.
|
||||
- If you expose the frontend or backend publicly (for teammates to test against), put it behind TLS with a real certificate. Do not let dev credentials travel over plain HTTP on the public internet.
|
||||
|
||||
---
|
||||
|
||||
## 10. What's not in this doc
|
||||
|
||||
- **Production deployment.** This is a dev-env doc. Production lives on Railway — see `CLAUDE.md`'s Deployment section.
|
||||
- **How to set up Traefik or any particular reverse proxy.** Whichever proxy you use is your choice; the dev stack just needs something that routes `<host>:5173` and `<host>:8000` to the right services. **Direct port exposure over a private network** (Tailscale, WireGuard, a VPN, or a LAN behind a firewall) is a fully supported option for dev and is what the homelab reference topology in Section 11 uses — no reverse proxy, no TLS, just `http://<host>:5173` and `http://<host>:8000` reachable only from the private network. That's a perfectly reasonable choice; it's just not the only one.
|
||||
- **How to configure code-server itself.** Install it however you prefer (native, Docker, LXC); point it at the repo, and the rest of this doc applies.
|
||||
- **Where to host the Proxmox instance.** Up to you.
|
||||
|
||||
If something in this doc turns out to be wrong on your host, fix the doc. This is a living document — the whole point of rewriting it from the Hostinger-specific version was to make it survive host changes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Reference topology: homelab Proxmox + code-server (Option B)
|
||||
|
||||
This section documents the first concrete host instantiation since the April 2026 host-agnostic rewrite. It's a worked example, not the canonical topology — Section 3's Option A/B/C framing still stands. If your setup looks different, follow Sections 1–10 and ignore this appendix.
|
||||
|
||||
### 11.1 Host
|
||||
|
||||
- **Hypervisor:** Proxmox (homelab).
|
||||
- **VM:** `docker-01`, Debian 13, running Docker Engine + Docker Compose natively.
|
||||
- **Tailscale IP:** `100.64.78.44`. MagicDNS hostname: `docker-01` (and the full `.ts.net` FQDN).
|
||||
- **code-server:** runs on the same VM in its own container, with the host's Docker socket mounted in so it can drive `docker compose`. Its workspace bind-mounts the repo at `/opt/docker/code-server/workspace/resolutionflow`.
|
||||
|
||||
This is a concrete instance of Option B from Section 3: Postgres, backend, and frontend all run as containers from `docker-compose.dev.yml`; the editor lives outside that compose network.
|
||||
|
||||
### 11.2 Access pattern — direct port over Tailscale, no reverse proxy
|
||||
|
||||
The browser reaches the dev stack directly:
|
||||
|
||||
- Frontend: `http://docker-01:5173`
|
||||
- Backend: `http://docker-01:8000`
|
||||
- Backend API docs: `http://docker-01:8000/api/docs`
|
||||
|
||||
There is **no Caddy, no Traefik, no nginx, no TLS, no basic auth** in front of either service. The tailnet provides the wire encryption and access control — only devices on the tailnet can resolve `docker-01` or reach `100.64.78.44`, and Tailscale ACLs decide which of those devices are allowed to connect.
|
||||
|
||||
Why this choice:
|
||||
|
||||
- **Zero routing config to maintain.** There is no proxy rulebook to keep in sync with new services. Add a container, expose a port, you're done.
|
||||
- **Backend-to-backend services stay private.** Redis, Celery workers, the planned ConnectWise proxy, the MCP server — none of them need to be reachable from the browser, so none of them need proxy rules. They stay inside the `resolutionflow` Docker network and talk by service name. The proxy would only ever have carried frontend and backend traffic, so the proxy's value was small relative to its maintenance cost.
|
||||
- **Debuggability.** `curl http://docker-01:8000/api/docs` from any tailnet device works without auth headers, TLS handshakes, or DNS shenanigans.
|
||||
|
||||
Tradeoff: **this only works because every client device is on the tailnet.** If someone needed to test from a non-tailnet device, they'd either join the tailnet or we'd need to front the stack with a proxy. For the current single-developer setup, the tailnet-only assumption holds.
|
||||
|
||||
### 11.3 Per-host config values (as actually configured on `docker-01`)
|
||||
|
||||
Plugging these into Section 4's template:
|
||||
|
||||
```
|
||||
DEV_HOST = docker-01
|
||||
DEV_HOST_SCHEME = http
|
||||
FRONTEND_PORT = 5173
|
||||
BACKEND_PORT = 8000
|
||||
POSTGRES_PORT = 5433 # host-side; container-internal stays 5432
|
||||
POSTGRES_DB_NAME = resolutionflow
|
||||
POSTGRES_USER = postgres
|
||||
POSTGRES_PASSWORD = postgres # local-dev only
|
||||
SECRET_KEY = <generated per host; do not reuse>
|
||||
ANTHROPIC_API_KEY = <from console.anthropic.com>
|
||||
GOOGLE_AI_API_KEY = <unset; Anthropic is sole provider in dev>
|
||||
```
|
||||
|
||||
And the repo-root `.env` that `docker-compose.dev.yml` interpolates from:
|
||||
|
||||
### Check disk
|
||||
```bash
|
||||
SECRET_KEY=<redacted>
|
||||
ANTHROPIC_API_KEY=<redacted>
|
||||
POSTGRES_PORT=5433
|
||||
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
|
||||
df -h
|
||||
```
|
||||
|
||||
### 11.4 Why `REPO_ROOT` is non-optional on this host
|
||||
|
||||
code-server runs inside a container. When you open a terminal in code-server and run `docker compose -f docker-compose.dev.yml up -d`, the Docker CLI talks to the *host* daemon via the mounted socket — but the CWD it reports (`/config/workspace/resolutionflow`) is a path that only exists inside the code-server container. The host daemon has never heard of it.
|
||||
|
||||
Relative bind mounts like `./backend:/app` therefore resolve against a path the host can't see, and Docker silently creates empty directories there rather than erroring out. The containers come up, but `/app` is empty.
|
||||
|
||||
`docker-compose.dev.yml` sidesteps this by using `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app`. `REPO_ROOT` must be set to the absolute path **on the host** (`/opt/docker/code-server/workspace/resolutionflow`), not the path inside the code-server container. Same contents, different mount point, different name.
|
||||
|
||||
If you ever run `docker compose` directly from a host shell (SSH'd into `docker-01`), set `REPO_ROOT` to `.` or the absolute host path. Being explicit is always safe; leaving it unset is the failure mode.
|
||||
|
||||
### 11.5 Vite `server.allowedHosts` — required for `docker-01` to resolve
|
||||
|
||||
Vite 5+ rejects any `Host:` header not in `server.allowedHosts` (DNS-rebinding protection). `frontend/vite.config.ts` has:
|
||||
|
||||
```ts
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['docker-01', '.ts.net', 'localhost'],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- `docker-01` — the MagicDNS short name the browser uses day-to-day.
|
||||
- `.ts.net` — wildcard for the full Tailscale MagicDNS FQDN, in case anyone uses it.
|
||||
- `localhost` — for the "am I serving anything at all" smoke-test from inside the container.
|
||||
|
||||
If you move this setup to a different host, add that host's hostname to `allowedHosts` or the browser will see "Blocked request. This host is not allowed." See Section 8's troubleshooting entry for the full symptom/fix.
|
||||
|
||||
### 11.6 CORS origins on this host
|
||||
|
||||
The `backend` service's `CORS_ORIGINS` environment variable is pinned in the compose file to:
|
||||
|
||||
```
|
||||
["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||
```
|
||||
|
||||
The last two are what make browser calls from tailnet clients work — they cover both MagicDNS (`docker-01`) and the raw Tailscale IP. If you add a new hostname to reach the frontend from, also add the matching origin here and restart the backend.
|
||||
|
||||
### 11.7 Compose file shape (as of this writing)
|
||||
|
||||
`docker-compose.dev.yml` has been through a round of cleanup for this topology. Specifics worth knowing if you're comparing against older revisions of the file:
|
||||
|
||||
- **No Traefik labels.** They were removed — nothing in this topology uses Traefik.
|
||||
- **No Hostinger-VPS-era origins** in `CORS_ORIGINS`.
|
||||
- `Dockerfile.dev` for both `backend` and `frontend` is still the build source — this didn't change.
|
||||
- Explicit `command:` directives on both `backend` (`uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`) and `frontend` (`npm run dev -- --host 0.0.0.0 --port 5173`) — this guarantees `--host 0.0.0.0` regardless of what's baked into the image, so the services listen on all interfaces and are reachable from outside the container.
|
||||
- `REPO_ROOT` is interpolated into both service volume mounts (see 11.4).
|
||||
|
||||
If you're adapting the file for a different host, the things most likely to need editing are `REPO_ROOT` (see 11.4), `CORS_ORIGINS` (see 11.6), `FRONTEND_URL`, `VITE_API_URL`, and `POSTGRES_PORT` if you want something other than `5433`.
|
||||
|
||||
### 11.8 End-to-end sanity check for this topology
|
||||
|
||||
From any device on the tailnet:
|
||||
|
||||
### Check memory + container usage
|
||||
```bash
|
||||
# Backend reachable
|
||||
curl -sSf http://docker-01:8000/api/docs >/dev/null && echo OK
|
||||
|
||||
# Frontend reachable
|
||||
curl -sSf http://docker-01:5173 >/dev/null && echo OK
|
||||
|
||||
# Alembic head matches the branch expectation
|
||||
docker exec resolutionflow_backend alembic current
|
||||
# expect f07010f17b01 on feat/flowpilot-migration, 074 on main
|
||||
|
||||
# Postgres is alive inside the compose network
|
||||
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT now();"
|
||||
free -h && docker stats --no-stream
|
||||
```
|
||||
|
||||
All four passing = the dev environment is live end-to-end.
|
||||
## DNS Records (resolutionflow.com)
|
||||
|
||||
| Type | Name | Value | Purpose |
|
||||
|---|---|---|---|
|
||||
| A | code | 46.202.92.250 | VS Code Server |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- UFW is inactive — Traefik and Docker manage port exposure
|
||||
- All public-facing services run through Traefik with valid HTTPS certs
|
||||
- PostgreSQL port 5432 is exposed on all interfaces — restrict if needed in production
|
||||
- Rotate API keys (Anthropic, Voyage) if ever exposed in logs or chat
|
||||
- Never commit `.env` files to Git
|
||||
|
||||
## VS Code Server Browser Tips
|
||||
|
||||
- **Command Palette:** `F1`
|
||||
- **Terminal:** Ctrl+`
|
||||
- **Rename file:** `F2`
|
||||
- **Go to definition:** `F12`
|
||||
- **Find references:** `Shift+F12`
|
||||
- **Context Menu:** `Alt + Right Click`
|
||||
70
SESSION-HANDOFF.md
Normal file
70
SESSION-HANDOFF.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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"`.
|
||||
@@ -5,12 +5,6 @@ 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 ./
|
||||
@@ -18,4 +12,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" ]
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Add account-scoped device_types table with platform seed data.
|
||||
|
||||
Revision ID: 073
|
||||
Revises: b3c7e9f2a1d8
|
||||
Create Date: 2026-04-12
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
|
||||
revision = "073"
|
||||
down_revision = "b3c7e9f2a1d8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
SYSTEM_DEVICE_TYPES = [
|
||||
("router", "Router", "network", 0),
|
||||
("switch", "Switch", "network", 1),
|
||||
("firewall", "Firewall", "network", 2),
|
||||
("access-point", "Access Point", "network", 3),
|
||||
("load-balancer", "Load Balancer", "network", 4),
|
||||
("server", "Server", "compute", 0),
|
||||
("workstation", "Workstation", "compute", 1),
|
||||
("vm", "Virtual Machine", "compute", 2),
|
||||
("container", "Container", "compute", 3),
|
||||
("nas", "NAS", "storage", 0),
|
||||
("san", "SAN", "storage", 1),
|
||||
("cloud-storage", "Cloud Storage", "storage", 2),
|
||||
("cloud", "Cloud", "cloud", 0),
|
||||
("aws", "AWS", "cloud", 1),
|
||||
("azure", "Azure", "cloud", 2),
|
||||
("gcp", "Google Cloud", "cloud", 3),
|
||||
("printer", "Printer", "endpoint", 0),
|
||||
("phone", "Phone", "endpoint", 1),
|
||||
("iot", "IoT Device", "endpoint", 2),
|
||||
("camera", "Camera", "endpoint", 3),
|
||||
("tablet", "Tablet", "endpoint", 4),
|
||||
("laptop", "Laptop", "endpoint", 5),
|
||||
("ups", "UPS", "infrastructure", 0),
|
||||
("pdu", "PDU", "infrastructure", 1),
|
||||
("rack", "Rack", "infrastructure", 2),
|
||||
("patch-panel", "Patch Panel", "infrastructure", 3),
|
||||
("nvr", "NVR", "security", 0),
|
||||
("badge-reader", "Badge Reader", "security", 1),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"device_types",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("slug", sa.String(50), nullable=False),
|
||||
sa.Column("label", sa.String(100), nullable=False),
|
||||
sa.Column("category", sa.String(50), nullable=False),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
op.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
|
||||
op.create_index("ix_device_types_account_id", "device_types", ["account_id"])
|
||||
|
||||
device_types_table = sa.table(
|
||||
"device_types",
|
||||
sa.column("id", UUID(as_uuid=True)),
|
||||
sa.column("slug", sa.String),
|
||||
sa.column("label", sa.String),
|
||||
sa.column("category", sa.String),
|
||||
sa.column("is_system", sa.Boolean),
|
||||
sa.column("account_id", UUID(as_uuid=True)),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
)
|
||||
|
||||
op.bulk_insert(device_types_table, [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"slug": slug,
|
||||
"label": label,
|
||||
"category": category,
|
||||
"is_system": True,
|
||||
"account_id": uuid.UUID(_PLATFORM_UUID),
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
|
||||
])
|
||||
|
||||
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_select ON device_types
|
||||
FOR SELECT
|
||||
USING (
|
||||
account_id = {_CURRENT_ACCOUNT}
|
||||
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||
)
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_insert ON device_types
|
||||
FOR INSERT
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_update ON device_types
|
||||
FOR UPDATE
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE POLICY device_types_delete ON device_types
|
||||
FOR DELETE
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
|
||||
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
|
||||
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("device_types")
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Add network_diagrams table.
|
||||
|
||||
Revision ID: 074
|
||||
Revises: 073
|
||||
Create Date: 2026-04-12
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
|
||||
revision = "074"
|
||||
down_revision = "073"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"network_diagrams",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("client_name", sa.String(255), nullable=True),
|
||||
sa.Column("asset_name", sa.String(255), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("thumbnail_url", sa.Text(), nullable=True),
|
||||
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
op.create_index("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
|
||||
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
|
||||
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON network_diagrams
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
|
||||
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("network_diagrams")
|
||||
@@ -1,30 +0,0 @@
|
||||
"""account_invites add revoked_at and email_sent_at
|
||||
|
||||
Revision ID: 2aa73d3231c2
|
||||
Revises: e1af7ab57ceb
|
||||
Create Date: 2026-05-06 07:28:28.514384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2aa73d3231c2'
|
||||
down_revision: Union[str, None] = 'e1af7ab57ceb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_account_invites_revoked_at", table_name="account_invites")
|
||||
op.drop_column("account_invites", "email_sent_at")
|
||||
op.drop_column("account_invites", "revoked_at")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""users add role_at_signup and onboarding_step_completed
|
||||
|
||||
Revision ID: 58e3caaa6269
|
||||
Revises: 5bb055a1593e
|
||||
Create Date: 2026-05-06 07:25:16.780761
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '58e3caaa6269'
|
||||
down_revision: Union[str, None] = '5bb055a1593e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True))
|
||||
op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "onboarding_step_completed")
|
||||
op.drop_column("users", "role_at_signup")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""users password_hash nullable
|
||||
|
||||
Revision ID: 5bb055a1593e
|
||||
Revises: b1fad5ddf357
|
||||
Create Date: 2026-05-06 07:23:21.480252
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5bb055a1593e'
|
||||
down_revision: Union[str, None] = 'b1fad5ddf357'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# NOTE: downgrade is non-trivial if any OAuth-only users exist.
|
||||
# This downgrade fails fast in that case rather than corrupting data.
|
||||
conn = op.get_bind()
|
||||
null_count = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL")
|
||||
).scalar()
|
||||
if null_count and null_count > 0:
|
||||
raise RuntimeError(
|
||||
f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. "
|
||||
"Set passwords or delete those rows before downgrading."
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(255),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -1,74 +0,0 @@
|
||||
"""add fix outcome tracking columns to session_suggested_fixes
|
||||
|
||||
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
|
||||
ai_outcome_proposal.
|
||||
|
||||
status is the outcome dimension (did the fix work?), orthogonal to the
|
||||
existing user_decision column (which script-path the engineer took).
|
||||
|
||||
Revision ID: 6492ec8d2d5b
|
||||
Revises: f07010f17b01
|
||||
Create Date: 2026-04-23 18:32:38.609719
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6492ec8d2d5b'
|
||||
down_revision: Union[str, None] = 'f07010f17b01'
|
||||
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("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("partial_notes", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
|
||||
op.execute(
|
||||
"UPDATE session_suggested_fixes "
|
||||
"SET status = 'dismissed' "
|
||||
"WHERE user_decision = 'dismissed'"
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')",
|
||||
)
|
||||
op.alter_column("session_suggested_fixes", "status", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
|
||||
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
|
||||
op.drop_column("session_suggested_fixes", "failure_reason")
|
||||
op.drop_column("session_suggested_fixes", "partial_notes")
|
||||
op.drop_column("session_suggested_fixes", "verified_at")
|
||||
op.drop_column("session_suggested_fixes", "applied_at")
|
||||
op.drop_column("session_suggested_fixes", "status")
|
||||
@@ -1,70 +0,0 @@
|
||||
"""add origin discriminator + inline idempotency to script_builder_sessions
|
||||
|
||||
Adds:
|
||||
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
|
||||
- invariant: pilot_inline rows must have ai_session_id
|
||||
- partial unique index: one pilot_inline session per (user, pilot session)
|
||||
|
||||
Revision ID: 71efd2102f49
|
||||
Revises: 6492ec8d2d5b
|
||||
Create Date: 2026-04-24 04:22:10.819809
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '71efd2102f49'
|
||||
down_revision = '6492ec8d2d5b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"script_builder_sessions",
|
||||
sa.Column(
|
||||
"origin",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'standalone'"),
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_script_builder_sessions_origin",
|
||||
"script_builder_sessions",
|
||||
"origin IN ('standalone', 'pilot_inline')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_script_builder_sessions_origin_ai_session",
|
||||
"script_builder_sessions",
|
||||
"origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
|
||||
)
|
||||
op.create_index(
|
||||
"ux_script_builder_sessions_pilot_inline",
|
||||
"script_builder_sessions",
|
||||
["user_id", "ai_session_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("origin = 'pilot_inline'"),
|
||||
)
|
||||
# Drop the server_default — app code owns the default via model default.
|
||||
op.alter_column("script_builder_sessions", "origin", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ux_script_builder_sessions_pilot_inline",
|
||||
table_name="script_builder_sessions",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_script_builder_sessions_origin_ai_session",
|
||||
"script_builder_sessions",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_script_builder_sessions_origin",
|
||||
"script_builder_sessions",
|
||||
type_="check",
|
||||
)
|
||||
op.drop_column("script_builder_sessions", "origin")
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""add oauth_identities
|
||||
|
||||
Revision ID: b1fad5ddf357
|
||||
Revises: c0f3a4b7e91d
|
||||
Create Date: 2026-05-06 07:17:11.374555
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b1fad5ddf357'
|
||||
down_revision: Union[str, None] = 'c0f3a4b7e91d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"oauth_identities",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("provider", sa.String(20), nullable=False),
|
||||
sa.Column("provider_subject", sa.String(255), nullable=False),
|
||||
sa.Column("provider_email_at_link", sa.String(255), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
|
||||
)
|
||||
op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities")
|
||||
op.drop_table("oauth_identities")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""subscriptions pilot complimentary backfill
|
||||
|
||||
This migration converts existing pilot/dev accounts to permanent complimentary
|
||||
Pro per the self-serve signup spec section 5. Forward-only; downgrade is
|
||||
prohibited because original status is not preserved.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c6cbfc534fad"
|
||||
down_revision: Union[str, None] = "c982a3fc4bf1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Set status='complimentary' and plan='pro' for all existing accounts that
|
||||
don't have a canceled or past_due subscription. Pilot users transition to
|
||||
permanent complimentary Pro per spec section 5.
|
||||
|
||||
Forward-only — does not preserve original status values."""
|
||||
conn = op.get_bind()
|
||||
# Update existing rows
|
||||
conn.execute(sa.text("""
|
||||
UPDATE subscriptions
|
||||
SET status = 'complimentary', plan = 'pro',
|
||||
current_period_end = NULL, current_period_start = NULL,
|
||||
updated_at = now()
|
||||
WHERE status NOT IN ('canceled', 'past_due')
|
||||
"""))
|
||||
# Backfill: any account without a Subscription row gets one
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
|
||||
FROM accounts a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade: original subscription state is not preserved. "
|
||||
"Restore from backup if needed."
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""add stripe_events
|
||||
|
||||
Revision ID: c982a3fc4bf1
|
||||
Revises: f7da3f93b519
|
||||
Create Date: 2026-05-06 07:32:08.027633
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c982a3fc4bf1'
|
||||
down_revision: Union[str, None] = 'f7da3f93b519'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"stripe_events",
|
||||
sa.Column("id", sa.String(length=255), primary_key=True, nullable=False),
|
||||
sa.Column("event_type", sa.String(length=100), nullable=False),
|
||||
sa.Column(
|
||||
"processed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"payload_excerpt",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_stripe_events_event_type", "stripe_events", ["event_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_stripe_events_event_type", table_name="stripe_events")
|
||||
op.drop_table("stripe_events")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""accounts add wizard columns
|
||||
|
||||
Revision ID: e1af7ab57ceb
|
||||
Revises: 58e3caaa6269
|
||||
Create Date: 2026-05-06 07:27:15.755518
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e1af7ab57ceb'
|
||||
down_revision: Union[str, None] = '58e3caaa6269'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True))
|
||||
op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("accounts", "primary_psa")
|
||||
op.drop_column("accounts", "team_size_bucket")
|
||||
@@ -1,404 +0,0 @@
|
||||
"""FlowPilot migration Phase 1 — schema for the unified session surface.
|
||||
|
||||
Revision ID: f07010f17b01
|
||||
Revises: 074
|
||||
Create Date: 2026-04-17
|
||||
|
||||
Creates the backing store for the FlowPilot unified session surface:
|
||||
|
||||
- `session_facts` — "What we know" facts, keyed to a session, with a polymorphic
|
||||
`source_ref` pointing at a task-lane item inside `ai_sessions.pending_task_lane`
|
||||
(no DB-level FK; integrity enforced at the service layer per the design doc).
|
||||
- `session_suggested_fixes` — AI-proposed resolution paths. Only one active
|
||||
(`superseded_at IS NULL`) per session at a time.
|
||||
- `draft_templates` — scripts pending post-resolve templatization
|
||||
(Option 2 in the three-option dialog).
|
||||
- `account_settings` — new per-account key/value settings table with a JSONB
|
||||
`preferences` grab-bag. Rows are created lazily on first write.
|
||||
- Column additions to `ai_sessions` — resolution/escalation markdown + external IDs,
|
||||
plus `state_version` (incremented by any write that invalidates the resolution
|
||||
note preview cache).
|
||||
- Column additions to `script_templates` — provenance fields for templates
|
||||
promoted from draft_templates.
|
||||
|
||||
All four new tenant-scoped tables have RLS enabled + forced with a
|
||||
`tenant_isolation` policy matching the repo pattern (USING + WITH CHECK on
|
||||
`account_id = app.current_account_id`). Downgrade is reversible: drops in the
|
||||
inverse order of creation.
|
||||
|
||||
Chained from `074` (add_network_diagrams_table) per the single-head state of
|
||||
production; the other local heads on feat/flowpilot-migration are branch
|
||||
artifacts not present in production.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
|
||||
revision = "f07010f17b01"
|
||||
down_revision = "074"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_CURRENT_ACCOUNT = (
|
||||
"COALESCE("
|
||||
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
"'00000000-0000-0000-0000-000000000000'"
|
||||
")::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ai_sessions: resolution / escalation columns + state_version ───────
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_markdown", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_posted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("resolution_note_external_id", sa.String(128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_markdown", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_posted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column("escalation_package_external_id", sa.String(128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"ai_sessions",
|
||||
sa.Column(
|
||||
"state_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
)
|
||||
|
||||
# ── script_templates: provenance for post-resolve promotion ────────────
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column(
|
||||
"source_session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column(
|
||||
"source_user_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"script_templates",
|
||||
sa.Column("source_ticket_ref", sa.String(64), nullable=True),
|
||||
)
|
||||
|
||||
# ── session_facts ──────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"session_facts",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column("source_type", sa.String(32), nullable=False),
|
||||
# `source_ref` is a polymorphic pointer to a task-lane item inside
|
||||
# ai_sessions.pending_task_lane JSON, NOT a FK to any table.
|
||||
# Integrity enforced at the service layer per Section 4.2 of the
|
||||
# migration design doc.
|
||||
sa.Column("source_ref", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("source_summary", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
|
||||
name="ck_session_facts_source_type",
|
||||
),
|
||||
)
|
||||
# Active-facts-per-session; partial index excludes soft-deleted rows.
|
||||
op.create_index(
|
||||
"idx_session_facts_session",
|
||||
"session_facts",
|
||||
["session_id"],
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_session_facts_account",
|
||||
"session_facts",
|
||||
["account_id"],
|
||||
)
|
||||
op.execute("ALTER TABLE session_facts ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE session_facts FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON session_facts
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── session_suggested_fixes ────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"session_suggested_fixes",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("confidence_pct", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"script_template_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("ai_drafted_script", sa.Text(), nullable=True),
|
||||
sa.Column("ai_drafted_parameters", JSONB(), nullable=True),
|
||||
sa.Column("user_decision", sa.String(32), nullable=True),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"confidence_pct BETWEEN 0 AND 100",
|
||||
name="ck_session_suggested_fixes_confidence_pct",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"user_decision IS NULL OR user_decision IN ("
|
||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_user_decision",
|
||||
),
|
||||
)
|
||||
# Only-one-active-per-session is enforced by service-layer supersession;
|
||||
# this partial index serves the "find active fix" query.
|
||||
op.create_index(
|
||||
"idx_session_suggested_fixes_session_active",
|
||||
"session_suggested_fixes",
|
||||
["session_id"],
|
||||
postgresql_where=sa.text("superseded_at IS NULL"),
|
||||
)
|
||||
op.execute("ALTER TABLE session_suggested_fixes ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE session_suggested_fixes FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON session_suggested_fixes
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── draft_templates ────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
"draft_templates",
|
||||
sa.Column(
|
||||
"id",
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
server_default=sa.text("gen_random_uuid()"),
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_session_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_sessions.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"source_user_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("script_body", sa.Text(), nullable=False),
|
||||
sa.Column("proposed_parameters", JSONB(), nullable=False),
|
||||
sa.Column("proposed_name", sa.String(200), nullable=True),
|
||||
sa.Column(
|
||||
"proposed_category_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_categories.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(32),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pending'"),
|
||||
),
|
||||
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"promoted_template_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_draft_templates_status",
|
||||
),
|
||||
)
|
||||
# Supports the Script Library "N scripts ready to review" badge.
|
||||
op.create_index(
|
||||
"idx_draft_templates_account_pending",
|
||||
"draft_templates",
|
||||
["account_id"],
|
||||
postgresql_where=sa.text("status = 'pending'"),
|
||||
)
|
||||
op.execute("ALTER TABLE draft_templates ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE draft_templates FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON draft_templates
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
# ── account_settings ───────────────────────────────────────────────────
|
||||
# One row per account, created lazily on first write. The `preferences`
|
||||
# JSONB is a grab-bag for simple settings (e.g. templatize_prompt_enabled).
|
||||
# Settings graduate to typed columns via future migrations when they meet
|
||||
# the promotion criteria in Section 4.6 of the design doc (hot path /
|
||||
# validation / joins).
|
||||
op.create_table(
|
||||
"account_settings",
|
||||
sa.Column(
|
||||
"account_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"preferences",
|
||||
JSONB(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
)
|
||||
op.execute("ALTER TABLE account_settings ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE account_settings FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON account_settings
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop in reverse order so FK dependencies unwind cleanly.
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON account_settings")
|
||||
op.execute("ALTER TABLE account_settings DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_table("account_settings")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON draft_templates")
|
||||
op.execute("ALTER TABLE draft_templates DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index("idx_draft_templates_account_pending", table_name="draft_templates")
|
||||
op.drop_table("draft_templates")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_suggested_fixes")
|
||||
op.execute("ALTER TABLE session_suggested_fixes DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index(
|
||||
"idx_session_suggested_fixes_session_active",
|
||||
table_name="session_suggested_fixes",
|
||||
)
|
||||
op.drop_table("session_suggested_fixes")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON session_facts")
|
||||
op.execute("ALTER TABLE session_facts DISABLE ROW LEVEL SECURITY")
|
||||
op.drop_index("idx_session_facts_account", table_name="session_facts")
|
||||
op.drop_index("idx_session_facts_session", table_name="session_facts")
|
||||
op.drop_table("session_facts")
|
||||
|
||||
op.drop_column("script_templates", "source_ticket_ref")
|
||||
op.drop_column("script_templates", "source_user_id")
|
||||
op.drop_column("script_templates", "source_session_id")
|
||||
|
||||
op.drop_column("ai_sessions", "state_version")
|
||||
op.drop_column("ai_sessions", "escalation_package_external_id")
|
||||
op.drop_column("ai_sessions", "escalation_package_posted_at")
|
||||
op.drop_column("ai_sessions", "escalation_package_markdown")
|
||||
op.drop_column("ai_sessions", "resolution_note_external_id")
|
||||
op.drop_column("ai_sessions", "resolution_note_posted_at")
|
||||
op.drop_column("ai_sessions", "resolution_note_markdown")
|
||||
@@ -1,41 +0,0 @@
|
||||
"""add plan_billing
|
||||
|
||||
Revision ID: f236a91224d0
|
||||
Revises: 2aa73d3231c2
|
||||
Create Date: 2026-05-06 07:30:06.807887
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f236a91224d0'
|
||||
down_revision: Union[str, None] = '2aa73d3231c2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plan_billing",
|
||||
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("monthly_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("annual_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("stripe_product_id", sa.String(255), nullable=True),
|
||||
sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True),
|
||||
sa.Column("stripe_annual_price_id", sa.String(255), nullable=True),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("plan_billing")
|
||||
@@ -1,57 +0,0 @@
|
||||
"""add sales_leads
|
||||
|
||||
Revision ID: f7da3f93b519
|
||||
Revises: f236a91224d0
|
||||
Create Date: 2026-05-06 07:31:39.533305
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f7da3f93b519'
|
||||
down_revision: Union[str, None] = 'f236a91224d0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sales_leads",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("company", sa.String(length=255), nullable=False),
|
||||
sa.Column("team_size", sa.String(length=20), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("posthog_distinct_id", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'new'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_sales_leads_email", "sales_leads", ["email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sales_leads_email", table_name="sales_leads")
|
||||
op.drop_table("sales_leads")
|
||||
@@ -83,12 +83,11 @@ async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> User:
|
||||
"""Ensure user is active (not disabled). Enforces must_change_password —
|
||||
blocks all routes except allowlist.
|
||||
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
|
||||
Enforces must_change_password — blocks all routes except allowlist.
|
||||
|
||||
Trial expiry enforcement now happens via require_active_subscription in
|
||||
individual routers, NOT here. This dep no longer mutates Subscription
|
||||
state.
|
||||
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
|
||||
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
@@ -107,6 +106,26 @@ async def get_current_active_user(
|
||||
# Set Sentry user context for error attribution
|
||||
sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email})
|
||||
|
||||
# Lightweight trial expiry check
|
||||
if current_user.account_id:
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import datetime, timezone
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == current_user.account_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if (
|
||||
subscription
|
||||
and subscription.status == "trialing"
|
||||
and subscription.current_period_end
|
||||
and subscription.current_period_end < datetime.now(timezone.utc)
|
||||
):
|
||||
subscription.plan = "free"
|
||||
subscription.status = "active"
|
||||
subscription.current_period_end = None
|
||||
subscription.current_period_start = None
|
||||
await db.commit()
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@@ -222,114 +241,3 @@ async def require_admin_db(
|
||||
the user object is needed in the handler.
|
||||
"""
|
||||
return db
|
||||
|
||||
|
||||
_SUBSCRIPTION_GUARD_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
}
|
||||
|
||||
|
||||
async def require_active_subscription(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
"""Returns the Subscription row when the account has access; raises 402
|
||||
when locked. Mounted on routers requiring Pro entitlement.
|
||||
|
||||
'Locked' = (trialing AND current_period_end < now()) OR
|
||||
(canceled OR incomplete OR no subscription).
|
||||
Active states: active, complimentary, trialing-with-time-remaining, past_due.
|
||||
"""
|
||||
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
|
||||
return None
|
||||
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import datetime, timezone
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == current_user.account_id)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
|
||||
if sub is None:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_live = (
|
||||
sub.status in ("active", "complimentary", "past_due")
|
||||
or (
|
||||
sub.status == "trialing"
|
||||
and sub.current_period_end is not None
|
||||
and sub.current_period_end > now
|
||||
)
|
||||
)
|
||||
if not is_live:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "subscription_inactive",
|
||||
"status": sub.status,
|
||||
"plan": sub.plan,
|
||||
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
|
||||
"upgrade_url": "/account/billing/select-plan",
|
||||
},
|
||||
)
|
||||
|
||||
return sub
|
||||
|
||||
|
||||
_EMAIL_VERIFICATION_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
}
|
||||
|
||||
VERIFICATION_GRACE_DAYS = 7
|
||||
|
||||
|
||||
async def require_verified_email_after_grace(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Enforces 'this user has verified email OR is still in 7-day grace.'
|
||||
OAuth signups bypass cleanly because /auth/{google,microsoft}/callback
|
||||
sets users.email_verified_at = now() (provider-attested)."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
|
||||
return
|
||||
|
||||
if current_user.email_verified_at is not None:
|
||||
return
|
||||
|
||||
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
|
||||
if datetime.now(timezone.utc) < grace_ends:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "email_not_verified",
|
||||
"grace_ended_at": grace_ends.isoformat(),
|
||||
"resend_url": "/api/v1/auth/email/send-verification",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -16,10 +16,9 @@ from app.models.refresh_token import RefreshToken
|
||||
from app.core.email import EmailService
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
||||
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
|
||||
from app.schemas.user import UserResponse, AccountRoleUpdate
|
||||
from app.core.security import verify_password
|
||||
@@ -260,7 +259,7 @@ async def create_invite(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Create an invite to join this account (owner only). Sends invite email."""
|
||||
"""Create an invite to join this account (owner only)."""
|
||||
code = secrets.token_urlsafe(16)
|
||||
|
||||
expires_at = None
|
||||
@@ -276,109 +275,11 @@ async def create_invite(
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(invite)
|
||||
await db.flush()
|
||||
|
||||
# Lookup account name for email
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one()
|
||||
|
||||
# Send invite email — non-blocking on failure (function returns False on error)
|
||||
email_sent = await EmailService.send_account_invite_email(
|
||||
to_email=invite.email,
|
||||
code=code,
|
||||
account_name=account.name,
|
||||
role=invite.role,
|
||||
)
|
||||
if email_sent:
|
||||
invite.email_sent_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
return invite
|
||||
|
||||
|
||||
@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_invites_bulk(
|
||||
payload: AccountInviteBulkCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Create multiple invites in one call (wizard step 3 supports up to N).
|
||||
Per-row failures are returned in `failed`; successes in `created`."""
|
||||
# Lookup account once for email rendering
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one()
|
||||
|
||||
created: list[AccountInvite] = []
|
||||
failed: list[dict] = []
|
||||
for invite_data in payload.invites:
|
||||
try:
|
||||
code = secrets.token_urlsafe(16)
|
||||
expires_at = None
|
||||
if invite_data.expires_in_days:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days)
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=current_user.account_id,
|
||||
invited_by_id=current_user.id,
|
||||
email=invite_data.email,
|
||||
code=code,
|
||||
role=invite_data.role,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(invite)
|
||||
await db.flush()
|
||||
|
||||
email_sent = await EmailService.send_account_invite_email(
|
||||
to_email=invite.email,
|
||||
code=code,
|
||||
account_name=account.name,
|
||||
role=invite.role,
|
||||
)
|
||||
if email_sent:
|
||||
invite.email_sent_at = datetime.now(timezone.utc)
|
||||
|
||||
created.append(invite)
|
||||
except Exception as e:
|
||||
failed.append({"email": invite_data.email, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
for inv in created:
|
||||
await db.refresh(inv)
|
||||
|
||||
return AccountInviteBulkResponse(created=created, failed=failed)
|
||||
|
||||
|
||||
@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_invite(
|
||||
invite_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Soft-revoke an invitation by setting revoked_at. Idempotent on already-
|
||||
revoked invites; rejects already-accepted invites."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite).where(
|
||||
AccountInvite.id == invite_id,
|
||||
AccountInvite.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
if invite.is_revoked:
|
||||
return None # idempotent
|
||||
if invite.is_used:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite")
|
||||
invite.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
|
||||
async def resend_invite(
|
||||
invite_id: UUID,
|
||||
@@ -658,65 +559,3 @@ async def get_sso_status(
|
||||
sso_enabled=account.sso_enabled,
|
||||
sso_provider=account.sso_provider,
|
||||
)
|
||||
|
||||
|
||||
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
|
||||
#
|
||||
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
|
||||
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
|
||||
# write. Any engineer-role user can read + update preferences because the
|
||||
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
|
||||
# are team-level toggles rather than account-owner-gated admin settings.
|
||||
|
||||
|
||||
class AccountPreferencesResponse(BaseModel):
|
||||
preferences: dict
|
||||
|
||||
|
||||
class AccountPreferencesUpdate(BaseModel):
|
||||
"""Merge-style update — each key in `preferences` overwrites that key in
|
||||
the stored JSONB, other keys are preserved. Omit the body entirely to
|
||||
no-op.
|
||||
"""
|
||||
preferences: dict
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def get_my_preferences(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Return the current account's preferences JSONB (empty dict if no row)."""
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def update_my_preferences(
|
||||
data: AccountPreferencesUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Upsert preference keys. Existing keys not present in the payload are kept.
|
||||
|
||||
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
|
||||
from the post-resolve "Don't ask me again for this team" checkbox sets
|
||||
just that key without clobbering any other preferences.
|
||||
"""
|
||||
for key, value in data.preferences.items():
|
||||
await AccountSettings.set_setting(db, current_user.account_id, key, value)
|
||||
await db.commit()
|
||||
|
||||
# Return the merged state so the client doesn't need a second GET.
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
@@ -431,19 +431,10 @@ async def create_account(
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Create a new account without requiring an initial user."""
|
||||
owner_id = None
|
||||
if data.owner_email:
|
||||
result = await db.execute(select(User).where(User.email == data.owner_email.strip()))
|
||||
owner = result.scalar_one_or_none()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No user found with email '{data.owner_email}'")
|
||||
owner_id = owner.id
|
||||
|
||||
display_code = await _generate_unique_display_code(db)
|
||||
new_account = Account(
|
||||
name=data.name.strip(),
|
||||
display_code=display_code,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(new_account)
|
||||
await db.flush()
|
||||
@@ -457,7 +448,7 @@ async def create_account(
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "account.create_admin", "account", new_account.id,
|
||||
{"name": new_account.name, "plan": data.plan, "owner_email": data.owner_email},
|
||||
{"name": new_account.name, "plan": data.plan},
|
||||
)
|
||||
await db.commit()
|
||||
return await _get_account_detail_payload(new_account.id, db)
|
||||
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import or_, select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -452,13 +452,6 @@ 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")
|
||||
@@ -466,62 +459,25 @@ 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 — 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)
|
||||
"""Escalate a FlowPilot session to another engineer."""
|
||||
try:
|
||||
handoff = await manager.create_handoff(
|
||||
result = await flowpilot_engine.escalate_session(
|
||||
session_id=session_id,
|
||||
intent="escalate",
|
||||
engineer_notes=data.escalation_reason,
|
||||
request=data,
|
||||
user_id=current_user.id,
|
||||
priority="normal",
|
||||
target_user_id=data.escalated_to_id,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
documentation, psa_result = await manager.finalize_escalation(
|
||||
handoff, session, current_user.id
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
|
||||
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,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Pause ──
|
||||
@@ -688,8 +644,7 @@ async def get_escalation_queue(
|
||||
select(AISession)
|
||||
.where(
|
||||
scope_filter,
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
AISession.user_id != current_user.id,
|
||||
AISession.status == "requesting_escalation",
|
||||
)
|
||||
.order_by(AISession.created_at.desc())
|
||||
)
|
||||
@@ -883,25 +838,13 @@ 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).
|
||||
|
||||
"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).
|
||||
"""
|
||||
"""List the current user's AI sessions (owned or picked up)."""
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -958,21 +901,10 @@ 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.
|
||||
# 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.
|
||||
# Allow access if user is owner, escalation target, or picked-up handler
|
||||
pkg = session.escalation_package or {}
|
||||
is_handler = pkg.get("picked_up_by") == str(current_user.id)
|
||||
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
|
||||
):
|
||||
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
return _build_session_detail(session)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -42,8 +41,6 @@ from app.core.email import EmailService
|
||||
from app.api.deps import get_current_active_user, get_refresh_token_payload
|
||||
from app.core.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@@ -65,22 +62,6 @@ def _generate_display_code() -> str:
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _reject_if_oauth_only(db: AsyncSession, user) -> None:
|
||||
"""If the user has no password_hash, raise 400 with a list of linked
|
||||
providers so the client can redirect them to the right OAuth flow."""
|
||||
if user is None or user.password_hash is not None:
|
||||
return
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
result = await db.execute(
|
||||
select(OAuthIdentity.provider).where(OAuthIdentity.user_id == user.id)
|
||||
)
|
||||
providers = [row for row in result.scalars().all()]
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "use_oauth_provider", "providers": providers},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("3/minute")
|
||||
async def register(
|
||||
@@ -127,12 +108,6 @@ async def register(
|
||||
detail="Account invite code has expired"
|
||||
)
|
||||
|
||||
if account_invite_record.email.lower() != user_data.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
# Validate platform invite code (skip if account invite was provided)
|
||||
invite_code_record = None
|
||||
if not account_invite_record:
|
||||
@@ -220,30 +195,26 @@ async def register(
|
||||
# Now set account owner and create subscription
|
||||
new_account.owner_id = new_user.id
|
||||
|
||||
# Apply plan/trial from invite code if present
|
||||
sub_plan = "free"
|
||||
sub_status = "active"
|
||||
period_start = None
|
||||
period_end = None
|
||||
if invite_code_record and invite_code_record.assigned_plan:
|
||||
# Plan/trial driven by platform invite code (existing pilot flow)
|
||||
sub_plan = invite_code_record.assigned_plan
|
||||
sub_status = "active"
|
||||
period_start = None
|
||||
period_end = None
|
||||
if invite_code_record.trial_duration_days:
|
||||
sub_status = "trialing"
|
||||
period_start = datetime.now(timezone.utc)
|
||||
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
|
||||
db.add(Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=sub_plan,
|
||||
status=sub_status,
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
))
|
||||
else:
|
||||
# New self-serve shop — start the standard Pro trial.
|
||||
# start_trial commits internally; flush our pending User/Account changes
|
||||
# first so the FK is satisfied.
|
||||
await db.flush()
|
||||
from app.services.billing import BillingService
|
||||
await BillingService.start_trial(db, new_account.id)
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=sub_plan,
|
||||
status=sub_status,
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
)
|
||||
db.add(new_subscription)
|
||||
|
||||
# Mark platform invite code as used
|
||||
if invite_code_record:
|
||||
@@ -253,34 +224,6 @@ async def register(
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
# Auto-send verification email for newly-registered users.
|
||||
# Skip silently if verification already done (shouldn't happen for fresh
|
||||
# users, but defensive).
|
||||
if new_user.email_verified_at is None:
|
||||
verification_enabled = await SettingsManager.get(
|
||||
"email_verification_enabled", db, default=True
|
||||
)
|
||||
if verification_enabled:
|
||||
try:
|
||||
raw_token = create_email_verification_token(str(new_user.id))
|
||||
payload = decode_token(raw_token)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = EmailVerificationToken(
|
||||
token_hash=hash_token(payload["jti"]),
|
||||
user_id=new_user.id,
|
||||
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
|
||||
)
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
|
||||
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
|
||||
await EmailService.send_email_verification_email(
|
||||
to_email=new_user.email,
|
||||
verification_url=verification_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("verification email send failed for %s: %s", new_user.email, e)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@@ -296,7 +239,6 @@ async def login(
|
||||
result = await db.execute(select(User).where(User.email == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
await _reject_if_oauth_only(db, user)
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -334,7 +276,6 @@ async def login_json(
|
||||
result = await db.execute(select(User).where(User.email == credentials.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
await _reject_if_oauth_only(db, user)
|
||||
if not user or not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -500,7 +441,6 @@ async def change_password(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
):
|
||||
"""Change the current user's password."""
|
||||
await _reject_if_oauth_only(db, current_user)
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -544,7 +484,7 @@ async def forgot_password(
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.password_hash is not None:
|
||||
if user:
|
||||
# Create reset token JWT
|
||||
raw_token = create_password_reset_token(str(user.id))
|
||||
payload = decode_token(raw_token)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.schemas.billing import (
|
||||
BillingStateResponse,
|
||||
CheckoutSessionCreate,
|
||||
CheckoutSessionResponse,
|
||||
)
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["billing"])
|
||||
|
||||
|
||||
@router.post("/checkout-session", response_model=CheckoutSessionResponse)
|
||||
async def create_checkout_session(
|
||||
payload: CheckoutSessionCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> CheckoutSessionResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
url = await BillingService.create_checkout_session(
|
||||
db=db,
|
||||
account=account,
|
||||
plan=payload.plan,
|
||||
seats=payload.seats,
|
||||
billing_interval=payload.billing_interval,
|
||||
success_url=f"{settings.FRONTEND_URL}/account/billing?success=1",
|
||||
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
|
||||
)
|
||||
return CheckoutSessionResponse(url=url)
|
||||
|
||||
|
||||
@router.get("/state", response_model=BillingStateResponse)
|
||||
async def get_billing_state(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingStateResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
state = await BillingService.get_billing_state(db, account)
|
||||
return BillingStateResponse(**state)
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Device types API endpoints."""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.models.device_type import DeviceType
|
||||
from app.schemas.device_type import (
|
||||
DeviceTypeCreate,
|
||||
DeviceTypeUpdate,
|
||||
DeviceTypeResponse,
|
||||
)
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
|
||||
router = APIRouter(prefix="/device-types", tags=["device-types"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DeviceTypeResponse])
|
||||
async def list_device_types(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> list[DeviceTypeResponse]:
|
||||
stmt = (
|
||||
select(DeviceType)
|
||||
.where(
|
||||
or_(
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [DeviceTypeResponse.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
|
||||
async def create_device_type(
|
||||
data: DeviceTypeCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DeviceTypeResponse:
|
||||
existing = await db.execute(
|
||||
select(DeviceType).where(
|
||||
DeviceType.slug == data.slug,
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
|
||||
|
||||
system_existing = await db.execute(
|
||||
select(DeviceType).where(
|
||||
DeviceType.slug == data.slug,
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
)
|
||||
)
|
||||
if system_existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
|
||||
|
||||
device_type = DeviceType(
|
||||
slug=data.slug,
|
||||
label=data.label,
|
||||
category=data.category,
|
||||
is_system=False,
|
||||
account_id=current_user.account_id,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(device_type)
|
||||
await db.commit()
|
||||
await db.refresh(device_type)
|
||||
return DeviceTypeResponse.model_validate(device_type)
|
||||
|
||||
|
||||
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
|
||||
async def update_device_type(
|
||||
device_type_id: UUID,
|
||||
data: DeviceTypeUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DeviceTypeResponse:
|
||||
device_type = await db.get(DeviceType, device_type_id)
|
||||
if not device_type:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
if device_type.is_system:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify system device types")
|
||||
if device_type.account_id != current_user.account_id:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(device_type, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(device_type)
|
||||
return DeviceTypeResponse.model_validate(device_type)
|
||||
|
||||
|
||||
@router.delete("/{device_type_id}", status_code=204)
|
||||
async def delete_device_type(
|
||||
device_type_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> None:
|
||||
device_type = await db.get(DeviceType, device_type_id)
|
||||
if not device_type:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
if device_type.is_system:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete system device types")
|
||||
if device_type.account_id != current_user.account_id:
|
||||
raise HTTPException(status_code=404, detail="Device type not found")
|
||||
|
||||
await db.delete(device_type)
|
||||
await db.commit()
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Draft template endpoints — Phase 6 post-resolve templatization flow.
|
||||
|
||||
Engineers who picked "Run now, templatize after resolve" on the three-option
|
||||
dialog (Phase 5) generate a `draft_templates` row at decision time. After
|
||||
the session resolves, the TemplatizePrompt component lets them either:
|
||||
- Accept → promotes the draft to a real `script_templates` row
|
||||
- Reject → marks the draft rejected, no library entry created
|
||||
|
||||
The Script Library sidebar uses the list endpoint to surface a
|
||||
"X drafts ready to review" badge for the account.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
from app.models.user import User
|
||||
from app.schemas.draft_template import (
|
||||
DraftTemplateAcceptRequest,
|
||||
DraftTemplateAcceptResponse,
|
||||
DraftTemplateListResponse,
|
||||
DraftTemplateRejectResponse,
|
||||
DraftTemplateResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/draft-templates", tags=["draft-templates"])
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII."""
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
|
||||
|
||||
# ── List ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=DraftTemplateListResponse)
|
||||
async def list_drafts(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
pending_only: bool = True,
|
||||
) -> DraftTemplateListResponse:
|
||||
"""List drafts for the current user's account.
|
||||
|
||||
Defaults to pending-only — that's what the Script Library badge counts
|
||||
and what the post-resolve TemplatizePrompt iterates over. Pass
|
||||
`pending_only=false` to include accepted/rejected for an audit view.
|
||||
"""
|
||||
stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc())
|
||||
if pending_only:
|
||||
stmt = stmt.where(DraftTemplate.status == "pending")
|
||||
result = await db.execute(stmt)
|
||||
drafts = list(result.scalars().all())
|
||||
return DraftTemplateListResponse(
|
||||
drafts=[DraftTemplateResponse.model_validate(d) for d in drafts]
|
||||
)
|
||||
|
||||
|
||||
# ── Get one ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftTemplateResponse)
|
||||
async def get_draft(
|
||||
draft_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateResponse:
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
return DraftTemplateResponse.model_validate(draft)
|
||||
|
||||
|
||||
# ── Accept ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/{draft_id}/accept",
|
||||
response_model=DraftTemplateAcceptResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def accept_draft(
|
||||
draft_id: UUID,
|
||||
body: DraftTemplateAcceptRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateAcceptResponse:
|
||||
"""Promote a draft to a real `script_templates` row.
|
||||
|
||||
Provenance fields (`source_session_id`, `source_user_id`,
|
||||
`source_ticket_ref`) are copied so the Script Library can render the
|
||||
"generated from CW #X · resolved by Y · used N times" chip.
|
||||
|
||||
On success: draft.status='accepted', draft.promoted_template_id set,
|
||||
draft.resolved_at set. The new template is owned by the engineer's team
|
||||
(matches scripts.create_template's behavior).
|
||||
|
||||
Returns 409 if the draft is already accepted/rejected.
|
||||
"""
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
if draft.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Draft is already {draft.status}",
|
||||
)
|
||||
|
||||
# Validate the category exists and belongs to (or is global for) this account.
|
||||
cat_result = await db.execute(
|
||||
select(ScriptCategory).where(
|
||||
ScriptCategory.id == body.category_id,
|
||||
ScriptCategory.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
if cat_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="category_id does not reference an active script category",
|
||||
)
|
||||
|
||||
# Look up source-session ticket ref for the provenance chip. RLS makes
|
||||
# cross-account ai_session lookup impossible — the draft must belong to
|
||||
# the same account as the requesting user.
|
||||
source_session = (
|
||||
await db.execute(
|
||||
select(AISession).where(AISession.id == draft.source_session_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
source_ticket_ref = (
|
||||
f"CW #{source_session.psa_ticket_id}"
|
||||
if source_session and source_session.psa_ticket_id
|
||||
else None
|
||||
)
|
||||
|
||||
slug = _slugify(body.name)
|
||||
|
||||
template = ScriptTemplate(
|
||||
category_id=body.category_id,
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
created_by=current_user.id,
|
||||
name=body.name,
|
||||
slug=slug,
|
||||
description=body.description,
|
||||
script_body=body.edited_body or draft.script_body,
|
||||
parameters_schema=body.parameters_schema,
|
||||
# FlowPilot provenance — drives the Script Library chip.
|
||||
source_session_id=draft.source_session_id,
|
||||
source_user_id=draft.source_user_id,
|
||||
source_ticket_ref=source_ticket_ref,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush() # populate template.id
|
||||
|
||||
draft.status = "accepted"
|
||||
draft.promoted_template_id = template.id
|
||||
draft.resolved_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
return DraftTemplateAcceptResponse(
|
||||
draft_id=draft.id,
|
||||
promoted_template_id=template.id,
|
||||
template_slug=template.slug,
|
||||
)
|
||||
|
||||
|
||||
# ── Reject ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse)
|
||||
async def reject_draft(
|
||||
draft_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateRejectResponse:
|
||||
"""Mark a draft rejected.
|
||||
|
||||
No template is created. The row stays for audit (so a team admin can see
|
||||
the engineer reviewed and explicitly declined). Returns 409 on a draft
|
||||
that's already accepted/rejected.
|
||||
"""
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
if draft.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Draft is already {draft.status}",
|
||||
)
|
||||
draft.status = "rejected"
|
||||
draft.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected")
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _load_draft_or_404(
|
||||
db: AsyncSession, draft_id: UUID
|
||||
) -> DraftTemplate:
|
||||
"""RLS-scoped draft load. 404 covers missing + cross-tenant."""
|
||||
result = await db.execute(
|
||||
select(DraftTemplate).where(DraftTemplate.id == draft_id)
|
||||
)
|
||||
draft = result.scalar_one_or_none()
|
||||
if draft is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Draft template not found",
|
||||
)
|
||||
return draft
|
||||
@@ -3,10 +3,8 @@
|
||||
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
|
||||
|
||||
@@ -15,17 +13,10 @@ 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_engineer_or_admin,
|
||||
require_team_admin,
|
||||
)
|
||||
from app.api.deps import get_current_active_user, get_db, 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
|
||||
@@ -45,7 +36,6 @@ from app.schemas.flowpilot_analytics import (
|
||||
EnhancedPsaMetrics,
|
||||
PsaFunnel,
|
||||
PsaDailyTrend,
|
||||
EscalationMetrics,
|
||||
)
|
||||
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
|
||||
|
||||
@@ -737,104 +727,3 @@ 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),
|
||||
)
|
||||
|
||||
@@ -194,7 +194,6 @@ 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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""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
|
||||
@@ -12,8 +11,6 @@ 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,20 +27,8 @@ from app.schemas.psa_connection import (
|
||||
PsaMemberMappingSaveRequest,
|
||||
PsaMemberResponse,
|
||||
AutoMatchResult,
|
||||
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,
|
||||
@@ -360,335 +345,43 @@ async def update_flowpilot_settings(
|
||||
# ── ticket / status / company endpoints ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/boards", response_model=list[PSABoardResponse])
|
||||
async def list_boards(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List PSA service boards."""
|
||||
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)
|
||||
boards = await provider.list_boards()
|
||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||
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=TicketListResponseSchema)
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
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 = 25,
|
||||
):
|
||||
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
|
||||
"""Search ConnectWise tickets."""
|
||||
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
|
||||
|
||||
member_identifier: str | None = None
|
||||
if assigned_to_me:
|
||||
conn_result = await db.execute(
|
||||
select(PsaConnection).where(
|
||||
PsaConnection.account_id == current_user.account_id,
|
||||
PsaConnection.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
conn = conn_result.scalar_one_or_none()
|
||||
if conn:
|
||||
mapping_result = await db.execute(
|
||||
select(PsaMemberMapping).where(
|
||||
PsaMemberMapping.psa_connection_id == conn.id,
|
||||
PsaMemberMapping.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
mapping = mapping_result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
try:
|
||||
_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 {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
except PSAError:
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
parsed_board_ids: list[int] = []
|
||||
if board_ids:
|
||||
try:
|
||||
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
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,
|
||||
tickets = await provider.search_tickets(
|
||||
query, board_id=board_id, status_id=status_id, include_closed=include_closed
|
||||
)
|
||||
items = [
|
||||
return [
|
||||
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 result.items
|
||||
for t in tickets
|
||||
]
|
||||
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,
|
||||
@@ -790,30 +483,7 @@ async def get_ticket_statuses(
|
||||
except PSANotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
except PSAError as 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 []
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
# ── member mapping endpoints ─────────────────────────────────────────
|
||||
@@ -821,7 +491,7 @@ async def get_board_statuses(
|
||||
|
||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||
async def list_members(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List CW members (from CW API)."""
|
||||
@@ -839,9 +509,7 @@ async def list_members(
|
||||
for m in members
|
||||
]
|
||||
except PSAError as e:
|
||||
# Members are optional display data — degrade gracefully
|
||||
logger.warning("list_members failed: %s", e)
|
||||
return []
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
@@ -849,37 +517,31 @@ async def get_member_mappings(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get all account users with their PSA member mappings (unmapped users included)."""
|
||||
"""Get all member mappings for the account."""
|
||||
conn = await _get_account_connection(current_user.account_id, db)
|
||||
if not conn:
|
||||
return []
|
||||
|
||||
# Fetch all active account users
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.account_id == current_user.account_id, User.is_active.is_(True))
|
||||
)
|
||||
users = users_result.scalars().all()
|
||||
|
||||
# Fetch all existing mappings keyed by user_id for O(1) lookup
|
||||
mappings_result = await db.execute(
|
||||
result = await db.execute(
|
||||
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||
)
|
||||
mapping_by_user: dict[str, PsaMemberMapping] = {
|
||||
str(m.user_id): m for m in mappings_result.scalars().all()
|
||||
}
|
||||
mappings = result.scalars().all()
|
||||
|
||||
return [
|
||||
PsaMemberMappingResponse(
|
||||
id=str(m.id) if (m := mapping_by_user.get(str(user.id))) else None,
|
||||
user_id=str(user.id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id if m else None,
|
||||
external_member_name=m.external_member_name if m else None,
|
||||
matched_by=m.matched_by if m else None,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
response = []
|
||||
for m in mappings:
|
||||
user_result = await db.execute(select(User).where(User.id == m.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
response.append(PsaMemberMappingResponse(
|
||||
id=str(m.id),
|
||||
user_id=str(m.user_id),
|
||||
user_email=user.email,
|
||||
user_name=user.name,
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
matched_by=m.matched_by,
|
||||
))
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
@@ -902,7 +564,6 @@ async def save_member_mappings(
|
||||
for m in mappings:
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=UUID(m.user_id),
|
||||
external_member_id=m.external_member_id,
|
||||
external_member_name=m.external_member_name,
|
||||
@@ -963,7 +624,6 @@ async def auto_match_members(
|
||||
if not existing.scalar_one_or_none():
|
||||
mapping = PsaMemberMapping(
|
||||
psa_connection_id=conn.id,
|
||||
account_id=current_user.account_id,
|
||||
user_id=user.id,
|
||||
external_member_id=cw_member.id,
|
||||
external_member_name=cw_member.name,
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
"""Network diagrams API endpoints."""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.models.device_type import DeviceType
|
||||
from app.models.network_diagram import NetworkDiagram
|
||||
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||
from app.schemas.network_diagram import (
|
||||
NetworkDiagramCreate,
|
||||
NetworkDiagramUpdate,
|
||||
NetworkDiagramResponse,
|
||||
NetworkDiagramListItem,
|
||||
AIGenerateRequest,
|
||||
AIGenerateResponse,
|
||||
DiagramImportRequest,
|
||||
DiagramImportResponse,
|
||||
DiagramExportResponse,
|
||||
DiagramNode,
|
||||
DiagramEdge,
|
||||
)
|
||||
from app.services import network_diagram_ai_service, storage_service
|
||||
|
||||
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||
_SLUG_CATEGORY: dict[str, str] = {
|
||||
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
|
||||
"firewall": "security", "badge-reader": "security",
|
||||
"server": "compute", "vm": "compute", "container": "compute",
|
||||
"nas": "storage", "san": "storage", "cloud-storage": "storage",
|
||||
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
|
||||
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
|
||||
"phone": "endpoint", "printer": "endpoint",
|
||||
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
|
||||
"patch-panel": "infrastructure", "camera": "infrastructure",
|
||||
"nvr": "infrastructure", "iot": "infrastructure",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
||||
|
||||
|
||||
async def _get_diagram_or_404(
|
||||
diagram_id: UUID,
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> NetworkDiagram:
|
||||
diagram = await db.get(NetworkDiagram, diagram_id)
|
||||
if not diagram or diagram.account_id != account_id or diagram.is_archived:
|
||||
raise HTTPException(status_code=404, detail="Diagram not found")
|
||||
return diagram
|
||||
|
||||
|
||||
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
|
||||
return NetworkDiagramResponse.model_validate(diagram)
|
||||
|
||||
|
||||
def _diagram_to_list_item(
|
||||
diagram: NetworkDiagram,
|
||||
custom_slug_category: dict[str, str] | None = None,
|
||||
) -> NetworkDiagramListItem:
|
||||
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
|
||||
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
|
||||
|
||||
category_counts: dict[str, int] = {}
|
||||
for node in nodes:
|
||||
slug = node.get("type", "") if isinstance(node, dict) else ""
|
||||
cat = slug_to_cat.get(slug, "other")
|
||||
category_counts[cat] = category_counts.get(cat, 0) + 1
|
||||
|
||||
return NetworkDiagramListItem(
|
||||
id=diagram.id,
|
||||
name=diagram.name,
|
||||
client_name=diagram.client_name,
|
||||
description=diagram.description,
|
||||
node_count=len(nodes),
|
||||
category_counts=category_counts,
|
||||
thumbnail_url=diagram.thumbnail_url,
|
||||
created_by=diagram.created_by,
|
||||
created_at=diagram.created_at,
|
||||
updated_at=diagram.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
|
||||
stmt = select(DeviceType.slug).where(
|
||||
or_(
|
||||
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||
DeviceType.account_id == account_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
|
||||
@router.get("/clients", response_model=list[str])
|
||||
async def list_client_names(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> list[str]:
|
||||
stmt = (
|
||||
select(NetworkDiagram.client_name)
|
||||
.where(
|
||||
NetworkDiagram.account_id == current_user.account_id,
|
||||
NetworkDiagram.is_archived.is_(False),
|
||||
NetworkDiagram.client_name.isnot(None),
|
||||
NetworkDiagram.client_name != "",
|
||||
)
|
||||
.distinct()
|
||||
.order_by(NetworkDiagram.client_name)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NetworkDiagramListItem])
|
||||
async def list_diagrams(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
client_name: str | None = Query(default=None),
|
||||
search: str | None = Query(default=None),
|
||||
) -> list[NetworkDiagramListItem]:
|
||||
stmt = (
|
||||
select(NetworkDiagram)
|
||||
.where(
|
||||
NetworkDiagram.account_id == current_user.account_id,
|
||||
NetworkDiagram.is_archived.is_(False),
|
||||
)
|
||||
.order_by(NetworkDiagram.updated_at.desc())
|
||||
)
|
||||
|
||||
if client_name:
|
||||
stmt = stmt.where(NetworkDiagram.client_name == client_name)
|
||||
|
||||
if search:
|
||||
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
search_filter = f"%{escaped}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
NetworkDiagram.name.ilike(search_filter),
|
||||
NetworkDiagram.client_name.ilike(search_filter),
|
||||
)
|
||||
)
|
||||
|
||||
# Single query for custom device types so category_counts is accurate
|
||||
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
|
||||
DeviceType.is_system.is_(False),
|
||||
DeviceType.account_id == current_user.account_id,
|
||||
)
|
||||
dt_result = await db.execute(dt_stmt)
|
||||
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [_diagram_to_list_item(r, custom_slug_category) for r in rows]
|
||||
|
||||
|
||||
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
|
||||
async def create_diagram(
|
||||
data: NetworkDiagramCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
diagram = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
name=data.name,
|
||||
client_name=data.client_name,
|
||||
asset_name=data.asset_name,
|
||||
description=data.description,
|
||||
nodes=[n.model_dump() for n in data.nodes],
|
||||
edges=[e.model_dump() for e in data.edges],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(diagram)
|
||||
await db.commit()
|
||||
await db.refresh(diagram)
|
||||
return _diagram_to_response(diagram)
|
||||
|
||||
|
||||
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
|
||||
async def get_diagram(
|
||||
diagram_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
return _diagram_to_response(diagram)
|
||||
|
||||
|
||||
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
|
||||
async def update_diagram(
|
||||
diagram_id: UUID,
|
||||
data: NetworkDiagramUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "nodes" in update_data and update_data["nodes"] is not None:
|
||||
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
|
||||
if "edges" in update_data and update_data["edges"] is not None:
|
||||
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(diagram, field, value)
|
||||
|
||||
diagram.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(diagram)
|
||||
return _diagram_to_response(diagram)
|
||||
|
||||
|
||||
@router.delete("/{diagram_id}", status_code=204)
|
||||
async def archive_diagram(
|
||||
diagram_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> None:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
diagram.is_archived = True
|
||||
diagram.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
|
||||
async def duplicate_diagram(
|
||||
diagram_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> NetworkDiagramResponse:
|
||||
source = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
copy = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
name=f"Copy of {source.name}",
|
||||
client_name=source.client_name,
|
||||
asset_name=source.asset_name,
|
||||
description=source.description,
|
||||
nodes=source.nodes,
|
||||
edges=source.edges,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(copy)
|
||||
await db.commit()
|
||||
await db.refresh(copy)
|
||||
return _diagram_to_response(copy)
|
||||
|
||||
|
||||
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
|
||||
async def export_diagram(
|
||||
diagram_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DiagramExportResponse:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
|
||||
edges = [DiagramEdge(**e) for e in (diagram.edges or [])]
|
||||
return DiagramExportResponse(
|
||||
schemaVersion=1,
|
||||
name=diagram.name,
|
||||
client_name=diagram.client_name,
|
||||
description=diagram.description,
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
exportedAt=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
|
||||
async def import_diagram(
|
||||
data: DiagramImportRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> DiagramImportResponse:
|
||||
available_slugs = await _get_available_slugs(current_user.account_id, db)
|
||||
|
||||
warnings: list[str] = []
|
||||
for node in data.nodes:
|
||||
if node.type not in available_slugs:
|
||||
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
|
||||
|
||||
diagram = NetworkDiagram(
|
||||
account_id=current_user.account_id,
|
||||
name=data.name,
|
||||
client_name=data.client_name,
|
||||
description=data.description,
|
||||
nodes=[n.model_dump() for n in data.nodes],
|
||||
edges=[e.model_dump() for e in data.edges],
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(diagram)
|
||||
await db.commit()
|
||||
await db.refresh(diagram)
|
||||
|
||||
return DiagramImportResponse(
|
||||
diagram=_diagram_to_response(diagram),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
class ThumbnailUploadRequest(BaseModel):
|
||||
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
|
||||
|
||||
|
||||
@router.post("/{diagram_id}/thumbnail", status_code=204)
|
||||
async def upload_thumbnail(
|
||||
diagram_id: UUID,
|
||||
body: ThumbnailUploadRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> None:
|
||||
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
|
||||
try:
|
||||
header, encoded = body.data_url.split(",", 1)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Invalid data URL format")
|
||||
image_bytes = base64.b64decode(encoded)
|
||||
storage_key = await storage_service.upload_file(
|
||||
file_data=image_bytes,
|
||||
filename=f"thumbnail-{diagram_id}.png",
|
||||
content_type="image/png",
|
||||
account_id=str(current_user.account_id),
|
||||
)
|
||||
presigned_url = storage_service.get_presigned_url(storage_key)
|
||||
diagram.thumbnail_url = presigned_url
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/ai-generate", response_model=AIGenerateResponse)
|
||||
async def ai_generate_diagram(
|
||||
data: AIGenerateRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> AIGenerateResponse:
|
||||
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
|
||||
available_slugs = list(available_slugs_set)
|
||||
|
||||
existing_node_ids: list[str] | None = None
|
||||
if data.mode == "merge" and data.existingBounds:
|
||||
existing_node_ids = []
|
||||
|
||||
try:
|
||||
return await network_diagram_ai_service.generate_diagram(
|
||||
request=data,
|
||||
available_slugs=available_slugs,
|
||||
existing_node_ids=existing_node_ids,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
except Exception:
|
||||
logger.exception("AI diagram generation failed")
|
||||
raise HTTPException(status_code=500, detail="Diagram generation failed")
|
||||
@@ -1,123 +0,0 @@
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.account import Account
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
|
||||
from app.services.billing import BillingService
|
||||
from app.services.oauth_providers import (
|
||||
google_exchange_code,
|
||||
microsoft_exchange_code,
|
||||
OAuthProfile,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth-oauth"])
|
||||
|
||||
|
||||
def _generate_display_code(length: int = 8) -> str:
|
||||
"""Match the helper used by /auth/register — A-Z + 0-9, length 8."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def _sign_in_or_register(
|
||||
db: AsyncSession, provider: str, profile: OAuthProfile
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject)."""
|
||||
identity = (
|
||||
await db.execute(
|
||||
select(OAuthIdentity).where(
|
||||
OAuthIdentity.provider == provider,
|
||||
OAuthIdentity.provider_subject == profile.provider_subject,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if identity:
|
||||
user = (
|
||||
await db.execute(select(User).where(User.id == identity.user_id))
|
||||
).scalar_one()
|
||||
return user, False
|
||||
|
||||
user = (
|
||||
await db.execute(select(User).where(User.email == profile.email))
|
||||
).scalar_one_or_none()
|
||||
is_new_user = user is None
|
||||
if is_new_user:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
|
||||
db.add(
|
||||
OAuthIdentity(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_subject=profile.provider_subject,
|
||||
provider_email_at_link=profile.email,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user, is_new_user
|
||||
|
||||
|
||||
@router.post("/google/callback", response_model=OAuthCallbackResponse)
|
||||
async def google_callback(
|
||||
payload: OAuthCallbackPayload,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> OAuthCallbackResponse:
|
||||
if not settings.GOOGLE_CLIENT_ID:
|
||||
raise HTTPException(status_code=503, detail="Google sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
|
||||
profile = await google_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "google", profile)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/microsoft/callback", response_model=OAuthCallbackResponse)
|
||||
async def microsoft_callback(
|
||||
payload: OAuthCallbackPayload,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> OAuthCallbackResponse:
|
||||
if not settings.MS_CLIENT_ID:
|
||||
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
|
||||
profile = await microsoft_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(db, "microsoft", profile)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
@@ -3,14 +3,12 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.models.script_builder_session import ScriptBuilderSession
|
||||
from app.schemas.script_builder import (
|
||||
@@ -69,85 +67,15 @@ async def create_session(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> ScriptBuilderSessionDetail:
|
||||
"""Start a new Script Builder session.
|
||||
|
||||
When origin='pilot_inline', behaves as get-or-create: the same row is
|
||||
returned on repeated calls with the same (user, ai_session_id) pair.
|
||||
Inline sessions are excluded from the session cap and the list endpoint.
|
||||
"""
|
||||
# Phase 9: inline origin validation + authorization
|
||||
if data.origin == "pilot_inline":
|
||||
if data.ai_session_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ai_session_id is required when origin='pilot_inline'",
|
||||
)
|
||||
# Ownership check: the pilot session must belong to the current user.
|
||||
ai_session = await db.scalar(
|
||||
select(AISession).where(
|
||||
AISession.id == data.ai_session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if ai_session is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
# Idempotent get-or-create: if a pilot_inline row already exists for
|
||||
# this (user, ai_session_id) pair, return it without creating a duplicate.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, existing.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# Create the inline session — wrap in IntegrityError catch for races.
|
||||
try:
|
||||
session = await script_builder_service.create_session(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
# Race: another request won the unique index — re-read the winner row.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
raise
|
||||
session = existing
|
||||
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, session.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# ── Standalone session ──────────────────────────────────────────────────
|
||||
"""Start a new Script Builder session."""
|
||||
# Acquire per-user advisory lock so concurrent create requests are serialized.
|
||||
# Without this, two simultaneous requests both read count < limit and both
|
||||
# insert, exceeding MAX_SESSIONS_PER_USER.
|
||||
user_lock_key = hash(str(current_user.id)) % (2**62)
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
|
||||
|
||||
# Enforce max concurrent sessions (inline sessions excluded from cap)
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
|
||||
# Enforce max concurrent sessions
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id)
|
||||
if count >= MAX_SESSIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -160,8 +88,6 @@ async def create_session(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
# Re-fetch with message_records loaded
|
||||
@@ -260,7 +186,6 @@ 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,
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_, literal, update as sa_update
|
||||
from sqlalchemy import select, func, or_, literal
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
@@ -374,20 +374,6 @@ async def generate_script(
|
||||
)
|
||||
db.add(generation)
|
||||
template.usage_count += 1
|
||||
|
||||
# FlowPilot Phase 3: bump the linked AI session's state_version so the
|
||||
# resolution-note preview cache invalidates. One-off scripts run outside
|
||||
# any FlowPilot session — in that case the UPDATE matches zero rows.
|
||||
if data.ai_session_id is not None:
|
||||
# Local import: scripts endpoint stays independent of AI-session
|
||||
# imports for non-AI generation paths.
|
||||
from app.models.ai_session import AISession
|
||||
await db.execute(
|
||||
sa_update(AISession)
|
||||
.where(AISession.id == data.ai_session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(generation)
|
||||
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Session fact endpoints — the "What we know" CRUD surface for a FlowPilot session.
|
||||
|
||||
All routes are sub-resources of `/ai-sessions/{session_id}`. Tenant isolation is
|
||||
enforced by RLS on `session_facts.account_id`; a user from another account
|
||||
literally cannot see or write facts for this session.
|
||||
|
||||
Editability rule (per FLOWPILOT-MIGRATION.md Section 7.3):
|
||||
- `user_note` and `ai_synthesis` facts are editable at the card level.
|
||||
- `question` and `diagnostic_check` facts are read-only at the card level —
|
||||
edit the source question/check instead. PATCH returns 403 for those.
|
||||
|
||||
Fact promotion writes always bump `ai_sessions.state_version` so the
|
||||
resolution-note preview cache invalidates (Section 5.5).
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_fact import SessionFact
|
||||
from app.models.user import User
|
||||
from app.schemas.session_fact import (
|
||||
SessionFactCreateRequest,
|
||||
SessionFactListResponse,
|
||||
SessionFactPromoteRequest,
|
||||
SessionFactResponse,
|
||||
SessionFactUpdateRequest,
|
||||
)
|
||||
from app.services.fact_synthesis_service import (
|
||||
FactSynthesisService,
|
||||
list_facts_for_session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-facts"])
|
||||
|
||||
# Source types whose facts can be edited at the card level (Section 7.3).
|
||||
_EDITABLE_SOURCE_TYPES = frozenset({"user_note", "ai_synthesis"})
|
||||
|
||||
|
||||
def _to_response(fact: SessionFact) -> SessionFactResponse:
|
||||
"""Wrap an ORM SessionFact in the response model with the editable flag."""
|
||||
return SessionFactResponse(
|
||||
id=fact.id,
|
||||
session_id=fact.session_id,
|
||||
text=fact.text,
|
||||
source_type=fact.source_type, # type: ignore[arg-type]
|
||||
source_ref=fact.source_ref,
|
||||
source_summary=fact.source_summary,
|
||||
created_by=fact.created_by,
|
||||
created_at=fact.created_at,
|
||||
updated_at=fact.updated_at,
|
||||
editable=fact.source_type in _EDITABLE_SOURCE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
|
||||
"""Load the session via RLS-scoped SELECT. Returns 404 if missing/cross-tenant.
|
||||
|
||||
Tenant isolation: RLS on `ai_sessions` filters by current account, so a
|
||||
cross-tenant access returns no rows and we 404 (rather than 403, which
|
||||
would leak the row's existence).
|
||||
"""
|
||||
result = await db.execute(select(AISession).where(AISession.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
async def _load_fact_or_404(
|
||||
db: AsyncSession, session_id: UUID, fact_id: UUID
|
||||
) -> SessionFact:
|
||||
"""Load a non-deleted fact for the session. 404 if missing or already deleted."""
|
||||
result = await db.execute(
|
||||
select(SessionFact).where(
|
||||
SessionFact.id == fact_id,
|
||||
SessionFact.session_id == session_id,
|
||||
SessionFact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
fact = result.scalar_one_or_none()
|
||||
if fact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fact not found")
|
||||
return fact
|
||||
|
||||
|
||||
# ── List ──
|
||||
|
||||
@router.get("/facts", response_model=SessionFactListResponse)
|
||||
async def list_facts(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionFactListResponse:
|
||||
"""List facts for a session, oldest first."""
|
||||
await _load_session_or_404(db, session_id)
|
||||
facts = await list_facts_for_session(db, session_id)
|
||||
return SessionFactListResponse(facts=[_to_response(f) for f in facts])
|
||||
|
||||
|
||||
# ── Create (manual user note) ──
|
||||
|
||||
@router.post("/facts", response_model=SessionFactResponse, status_code=201)
|
||||
async def create_fact(
|
||||
session_id: UUID,
|
||||
body: SessionFactCreateRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionFactResponse:
|
||||
"""Create a manual fact (the "+ Add a note" UI affordance).
|
||||
|
||||
Always recorded as `source_type=user_note`. Source-typed creation goes
|
||||
through `/facts/promote` so the originating item ID is captured.
|
||||
"""
|
||||
session = await _load_session_or_404(db, session_id)
|
||||
service = FactSynthesisService(db)
|
||||
try:
|
||||
fact = await service.create_fact(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
user_id=current_user.id,
|
||||
source_type="user_note",
|
||||
text=body.text,
|
||||
summary=body.summary,
|
||||
source_ref=None,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
await db.commit()
|
||||
await db.refresh(fact)
|
||||
return _to_response(fact)
|
||||
|
||||
|
||||
# ── Update ──
|
||||
|
||||
@router.patch("/facts/{fact_id}", response_model=SessionFactResponse)
|
||||
async def update_fact(
|
||||
session_id: UUID,
|
||||
fact_id: UUID,
|
||||
body: SessionFactUpdateRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionFactResponse:
|
||||
"""Edit fact text or summary.
|
||||
|
||||
Returns 403 for `question` and `diagnostic_check`-sourced facts: the
|
||||
source item is the canonical input, so editing the fact card would
|
||||
desync the two. Engineers edit the source instead.
|
||||
"""
|
||||
fact = await _load_fact_or_404(db, session_id, fact_id)
|
||||
if fact.source_type not in _EDITABLE_SOURCE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"Facts sourced from {fact.source_type!r} are read-only at the "
|
||||
"card level. Edit the originating question or diagnostic check instead."
|
||||
),
|
||||
)
|
||||
|
||||
if body.text is None and body.summary is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one of `text` or `summary` must be provided",
|
||||
)
|
||||
|
||||
service = FactSynthesisService(db)
|
||||
try:
|
||||
fact = await service.update_fact(fact, text=body.text, summary=body.summary)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
await db.commit()
|
||||
await db.refresh(fact)
|
||||
return _to_response(fact)
|
||||
|
||||
|
||||
# ── Soft delete ──
|
||||
|
||||
@router.delete("/facts/{fact_id}", status_code=204)
|
||||
async def delete_fact(
|
||||
session_id: UUID,
|
||||
fact_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> None:
|
||||
"""Soft-delete a fact. All source types are deletable.
|
||||
|
||||
Soft delete (rather than hard) preserves provenance for audit and lets
|
||||
accidental deletes be recovered if needed. The `editable` flag does NOT
|
||||
control deletion — even read-only facts can be removed when the
|
||||
underlying question/check turned out to be wrong.
|
||||
"""
|
||||
fact = await _load_fact_or_404(db, session_id, fact_id)
|
||||
service = FactSynthesisService(db)
|
||||
await service.soft_delete_fact(fact)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Promote (AI marker + engineer-driven) ──
|
||||
|
||||
@router.post("/facts/promote", response_model=SessionFactResponse, status_code=201)
|
||||
async def promote_fact(
|
||||
session_id: UUID,
|
||||
body: SessionFactPromoteRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionFactResponse:
|
||||
"""Convert a question answer / check result into a fact.
|
||||
|
||||
Two modes:
|
||||
|
||||
- `proposed_text` provided → persisted as-is.
|
||||
- `raw_input` provided → server drafts text/summary via FactSynthesisService.
|
||||
|
||||
Exactly one of the two must be set. The engineer-facing UI typically uses
|
||||
`proposed_text` after letting the engineer review/edit a draft.
|
||||
"""
|
||||
if (body.proposed_text is None) == (body.raw_input is None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Exactly one of `proposed_text` or `raw_input` must be provided",
|
||||
)
|
||||
if body.source_type == "ai_synthesis" and body.source_ref is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="`source_ref` must be null for source_type=ai_synthesis",
|
||||
)
|
||||
|
||||
session = await _load_session_or_404(db, session_id)
|
||||
service = FactSynthesisService(db)
|
||||
|
||||
text = body.proposed_text
|
||||
summary = body.proposed_summary
|
||||
if text is None:
|
||||
# Synthesize via LLM. Caller must hint which task-lane item the input
|
||||
# came from so we can shape the prompt appropriately.
|
||||
raw = body.raw_input or ""
|
||||
if body.source_type == "question":
|
||||
draft = await service.synthesize_from_question(
|
||||
question_text=_lookup_task_lane_text(session, body.source_ref, "questions"),
|
||||
raw_answer=raw,
|
||||
)
|
||||
elif body.source_type == "diagnostic_check":
|
||||
draft = await service.synthesize_from_check(
|
||||
check_label=_lookup_task_lane_text(session, body.source_ref, "actions"),
|
||||
check_output=raw,
|
||||
)
|
||||
else:
|
||||
# ai_synthesis with raw_input: the raw input IS the synthesis.
|
||||
# Re-run through the question synthesizer with an empty question
|
||||
# so the conservative prompt still applies.
|
||||
draft = await service.synthesize_from_question(
|
||||
question_text="(none — synthesizing from engineer summary)",
|
||||
raw_answer=raw,
|
||||
)
|
||||
text = draft["text"]
|
||||
summary = summary or draft["summary"]
|
||||
if not text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
"Synthesizer found no substantive fact in the input. "
|
||||
"Edit the input or supply `proposed_text` directly."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
fact = await service.create_fact(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
user_id=current_user.id,
|
||||
source_type=body.source_type,
|
||||
text=text,
|
||||
summary=summary,
|
||||
source_ref=body.source_ref,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fact)
|
||||
return _to_response(fact)
|
||||
|
||||
|
||||
def _lookup_task_lane_text(
|
||||
session: AISession, source_ref: UUID | None, list_key: str
|
||||
) -> str:
|
||||
"""Find the originating question text / action label from pending_task_lane.
|
||||
|
||||
Falls back to a generic placeholder if the source item is no longer in
|
||||
the lane (e.g., the AI dropped it from a later turn). The synthesizer is
|
||||
forgiving — an empty/generic question still produces a useful fact when
|
||||
the engineer's answer is substantive on its own.
|
||||
"""
|
||||
if source_ref is None:
|
||||
return ""
|
||||
lane = session.pending_task_lane or {}
|
||||
items = lane.get(list_key) or []
|
||||
sref = str(source_ref)
|
||||
for item in items:
|
||||
if isinstance(item, dict) and str(item.get("id")) == sref:
|
||||
return str(item.get("text") or item.get("label") or "")
|
||||
return ""
|
||||
@@ -1,28 +1,23 @@
|
||||
"""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/escalations/stream — SSE: live escalation arrivals
|
||||
GET /ai-sessions/queue — Team queue
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated, AsyncGenerator
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.api.deps import get_current_active_user, get_db
|
||||
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 HandoffAlreadyClaimedError, HandoffManager
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
from app.schemas.session_handoff import (
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
@@ -41,7 +36,6 @@ 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:
|
||||
@@ -64,35 +58,12 @@ 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()
|
||||
|
||||
# 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}
|
||||
)
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
|
||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||
@@ -115,49 +86,21 @@ async def list_handoffs(
|
||||
async def claim_handoff(
|
||||
session_id: UUID,
|
||||
handoff_id: UUID,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
"""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.
|
||||
"""
|
||||
"""Claim a handed-off session."""
|
||||
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()
|
||||
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}
|
||||
)
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
|
||||
@queue_router.get("/queue")
|
||||
@@ -171,83 +114,3 @@ 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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,768 +0,0 @@
|
||||
"""Suggested-fix + resolution-note / escalation-package preview-and-post endpoints.
|
||||
|
||||
Phase 3: active suggested fix lookup + decision recording, resolution-note
|
||||
preview with state_version cache.
|
||||
|
||||
Phase 4: resolution-note POST (writeback to PSA + mark resolved), escalation
|
||||
package preview + POST (writeback + mark escalated). Local-only path when
|
||||
the session has no linked PSA ticket: markdown is stored on the session and
|
||||
the status flipped, no external call.
|
||||
|
||||
Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||
from app.models.user import User
|
||||
from app.schemas.session_suggested_fix import (
|
||||
EscalationPackagePostRequest,
|
||||
ResolutionNotePostRequest,
|
||||
ResolutionNotePreviewResponse,
|
||||
ResolutionPostResponse,
|
||||
SessionSuggestedFixDecisionRequest,
|
||||
SessionSuggestedFixDecisionResponse,
|
||||
SessionSuggestedFixOutcomeRequest,
|
||||
SessionSuggestedFixResponse,
|
||||
SessionSuggestedFixScriptRequest,
|
||||
)
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.session_fact import SessionFact
|
||||
from app.services.escalation_package_generator import EscalationPackageGeneratorService
|
||||
from app.services.preview_cache import preview_cache
|
||||
from app.services.psa_writeback_service import (
|
||||
PSAStatusVerificationError,
|
||||
PSAWritebackService,
|
||||
)
|
||||
from app.services.resolution_note_generator import ResolutionNoteGeneratorService
|
||||
from app.services.template_extraction_service import extract_parameters as _extract_template_parameters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-suggested-fixes"])
|
||||
|
||||
|
||||
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
|
||||
"""RLS-scoped session load. 404 covers both missing and cross-tenant."""
|
||||
result = await db.execute(select(AISession).where(AISession.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
# ── Suggested fix: active ──────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/suggested-fixes/active",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def get_active_suggested_fix(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Return the current active suggested fix (`superseded_at IS NULL`) or 404.
|
||||
|
||||
A session has at most one active fix. Multiple historical rows persist
|
||||
for audit, but only the most-recent un-superseded one is returned here.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix)
|
||||
.where(
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
SessionSuggestedFix.superseded_at.is_(None),
|
||||
)
|
||||
.order_by(SessionSuggestedFix.created_at.desc())
|
||||
)
|
||||
fix = result.scalars().first()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active suggested fix for this session",
|
||||
)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
# ── Suggested fix: decision ────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/suggested-fixes/{fix_id}/decision",
|
||||
response_model=SessionSuggestedFixDecisionResponse,
|
||||
)
|
||||
async def record_decision(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
body: SessionSuggestedFixDecisionRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixDecisionResponse:
|
||||
"""Record the engineer's path choice on a suggested fix.
|
||||
|
||||
Phase 3 recorded the choice and (for `dismissed`) superseded the fix.
|
||||
Phase 5 adds side effects: one_off / draft_template return the rendered
|
||||
script; draft_template also creates a `draft_templates` row via the
|
||||
TemplateExtractionService; build_template returns a redirect to the
|
||||
Script Builder.
|
||||
"""
|
||||
session_obj = await _load_session_or_404(db, session_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
# Once a fix has been superseded we still record the engineer's
|
||||
# decision (it's a historical signal — "engineer dismissed the
|
||||
# interim hypothesis"), but `dismissed` on a superseded row would
|
||||
# be redundant noise.
|
||||
if fix.superseded_at is not None and body.decision == "dismissed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This fix is already superseded by a newer suggestion",
|
||||
)
|
||||
|
||||
fix.user_decision = body.decision
|
||||
if body.decision == "dismissed" and fix.superseded_at is None:
|
||||
fix.superseded_at = datetime.now(timezone.utc)
|
||||
|
||||
# Engineer's choice changes the bundle the resolution-note preview sees,
|
||||
# so bump state_version too.
|
||||
await db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
|
||||
rendered_script: str | None = None
|
||||
draft_template_id: UUID | None = None
|
||||
redirect_path: str | None = None
|
||||
|
||||
# Phase 5 side effects. All three non-dismiss paths assume the fix has
|
||||
# either a script_template_id (template match — use the dedicated
|
||||
# /scripts/generate endpoint from the frontend, not this one) or an
|
||||
# ai_drafted_script (custom script — this is the entry point).
|
||||
if body.decision in ("one_off", "draft_template", "build_template"):
|
||||
drafted = body.edited_script or fix.ai_drafted_script
|
||||
if not drafted:
|
||||
# Template-matched fixes take the regular /scripts/generate path.
|
||||
# If a fix somehow reaches here without a drafted script AND
|
||||
# without a template, that's a client-side wiring bug.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
"Suggested fix has no ai_drafted_script — use "
|
||||
"/api/v1/scripts/generate for template-matched fixes."
|
||||
),
|
||||
)
|
||||
rendered_script = drafted.strip()
|
||||
|
||||
if body.decision == "draft_template":
|
||||
# TemplateExtractionService proposes the parameterization. Runs
|
||||
# under the same transaction so a failure rolls back the decision.
|
||||
session_ctx = await _summarize_session_for_extraction(db, session_id)
|
||||
extraction = await _extract_template_parameters(
|
||||
script_body=rendered_script or "",
|
||||
session_context=session_ctx,
|
||||
ticket_context=None, # ticket context wiring lands in Phase 5 polish
|
||||
)
|
||||
|
||||
draft = DraftTemplate(
|
||||
account_id=session_obj.account_id,
|
||||
source_session_id=session_obj.id,
|
||||
source_user_id=current_user.id,
|
||||
script_body=extraction["templated_body"] or (rendered_script or ""),
|
||||
proposed_parameters={"parameters": extraction["parameters"]},
|
||||
proposed_name=fix.title[:200] if fix.title else None,
|
||||
status="pending",
|
||||
)
|
||||
db.add(draft)
|
||||
await db.flush()
|
||||
draft_template_id = draft.id
|
||||
|
||||
if body.decision == "build_template":
|
||||
# Frontend navigates to the Script Builder preloaded with the
|
||||
# drafted body. The builder wires the full parameterization flow;
|
||||
# we hand it a scratch-pad query string, not persistent state.
|
||||
redirect_path = (
|
||||
f"/scripts/builder?from_session={session_obj.id}&fix={fix.id}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
|
||||
return SessionSuggestedFixDecisionResponse(
|
||||
id=fix.id,
|
||||
user_decision=fix.user_decision, # type: ignore[arg-type]
|
||||
rendered_script=rendered_script,
|
||||
draft_template_id=draft_template_id,
|
||||
redirect_path=redirect_path,
|
||||
)
|
||||
|
||||
|
||||
# ── Suggested fix: apply (stamp applied_at) ──────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/suggested-fixes/{fix_id}/apply",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def apply_suggested_fix(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
||||
|
||||
This does NOT change status (fix remains 'proposed'). Status only flips
|
||||
when the engineer records an outcome via PATCH /outcome.
|
||||
|
||||
Rules:
|
||||
- Fix must be in 'proposed' status; any other status → 409.
|
||||
- Idempotent: if applied_at is already set, returns 200 with the unchanged row.
|
||||
- Bumps ai_sessions.state_version so resolve/escalate preview generators
|
||||
know the fix has entered the verifying phase.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
if fix.status != "proposed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Apply is only valid from 'proposed'; fix is already '{fix.status}'",
|
||||
)
|
||||
|
||||
# Idempotent: already stamped → return as-is without bumping state_version again.
|
||||
if fix.applied_at is not None:
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
fix.applied_at = datetime.now(timezone.utc)
|
||||
|
||||
# Bump state_version so preview generators see the verifying-phase signal.
|
||||
await db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
# ── Suggested fix: outcome ────────────────────────────────────────────────
|
||||
|
||||
@router.patch(
|
||||
"/suggested-fixes/{fix_id}/outcome",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def patch_suggested_fix_outcome(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
body: SessionSuggestedFixOutcomeRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Record the engineer's outcome for an applied fix.
|
||||
|
||||
See `SessionSuggestedFixOutcomeRequest` for transition rules.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
if body.outcome == "applied_partial" 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_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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Fix is already in terminal status {fix.status!r}",
|
||||
)
|
||||
|
||||
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
|
||||
elif body.outcome == "applied_success":
|
||||
fix.verified_at = now
|
||||
# dismissed: no timestamp/notes stamping
|
||||
|
||||
if fix.applied_at is None and body.outcome != "dismissed":
|
||||
fix.applied_at = now
|
||||
|
||||
# Clear any pending AI outcome proposal — engineer has taken a terminal action.
|
||||
fix.ai_outcome_proposal = None
|
||||
|
||||
# Outcome changes the bundle that resolution-note/escalation-package
|
||||
# previews see, so bump state_version inside the same transaction —
|
||||
# mirrors the pattern in record_decision above.
|
||||
await db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
# ── Suggested fix: attach drafted script ─────────────────────────────────────
|
||||
|
||||
@router.patch(
|
||||
"/suggested-fixes/{fix_id}/script",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def patch_suggested_fix_script(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
body: SessionSuggestedFixScriptRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Attach an engineer-drafted script to a suggested fix.
|
||||
|
||||
Called by the inline Script Builder tab on Submit. Does NOT stamp
|
||||
applied_at — a draft is not an application. Bumps state_version so
|
||||
the Resolve/Escalate preview bundles regenerate.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
|
||||
fix = await db.scalar(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
if fix is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found")
|
||||
|
||||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||
if fix.status in TERMINAL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Fix is already in terminal status {fix.status!r}",
|
||||
)
|
||||
|
||||
fix.ai_drafted_script = body.ai_drafted_script
|
||||
fix.ai_drafted_parameters = body.ai_drafted_parameters
|
||||
|
||||
# Bump state_version on the parent session — previews cached by
|
||||
# (session_id, state_version) must regenerate to reflect the new draft.
|
||||
await db.execute(
|
||||
update(AISession)
|
||||
.where(AISession.id == session_id)
|
||||
.values(state_version=AISession.state_version + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
# ── Suggested fix: clear AI outcome proposal ("Not yet") ─────────────────────
|
||||
|
||||
@router.delete(
|
||||
"/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def clear_ai_outcome_proposal(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Explicitly dismiss the AI-proposed outcome banner ("Not yet").
|
||||
|
||||
Clears `ai_outcome_proposal` without touching status or state_version
|
||||
(this is pure UI state, not outcome data). Idempotent: returns 200 even
|
||||
when the field is already null. After this call the banner will not
|
||||
re-surface on the next refreshSessionDerived unless the AI emits a new
|
||||
proposal.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
fix.ai_outcome_proposal = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
async def _summarize_session_for_extraction(
|
||||
db: AsyncSession, session_id: UUID,
|
||||
) -> str:
|
||||
"""Compact fact list for TemplateExtractionService context.
|
||||
|
||||
We don't send the full chat transcript — the extractor only needs enough
|
||||
signal to decide which values in the script are session-specific (and
|
||||
therefore worth parameterizing).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(SessionFact)
|
||||
.where(
|
||||
SessionFact.session_id == session_id,
|
||||
SessionFact.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(SessionFact.created_at.asc())
|
||||
)
|
||||
facts = list(result.scalars().all())
|
||||
if not facts:
|
||||
return ""
|
||||
lines = [f"- {f.text}" for f in facts]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Resolution note preview ────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/resolution-note/preview",
|
||||
response_model=ResolutionNotePreviewResponse,
|
||||
)
|
||||
async def resolution_note_preview(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> ResolutionNotePreviewResponse:
|
||||
"""Generate (or return cached) draft markdown for the Resolve note.
|
||||
|
||||
Cache key: `(resolution_note, session_id, state_version)`. State_version is
|
||||
bumped by every fact / suggested-fix / script-generation write, so two
|
||||
consecutive calls with no intervening writes return the same cached
|
||||
payload (and won't pay for a Sonnet call).
|
||||
|
||||
Posted to PSA in Phase 4. Until then, this endpoint is read-only.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
gen = ResolutionNoteGeneratorService(db)
|
||||
try:
|
||||
payload = await gen.generate_or_get_cached(session_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Resolution note preview failed for session %s", session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Resolution-note generator error ({type(e).__name__})",
|
||||
)
|
||||
return ResolutionNotePreviewResponse(**payload)
|
||||
|
||||
|
||||
# ── Phase 4: escalation-package preview ────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/escalation-package/preview",
|
||||
response_model=ResolutionNotePreviewResponse,
|
||||
)
|
||||
async def escalation_package_preview(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> ResolutionNotePreviewResponse:
|
||||
"""Generate (or return cached) draft markdown for the Escalate handoff package.
|
||||
|
||||
Same caching story as the resolution-note preview: keyed on
|
||||
`(session_id, state_version)`. Separate cache kind so a Resolve preview
|
||||
and an Escalate preview for the same state can coexist.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
gen = EscalationPackageGeneratorService(db)
|
||||
try:
|
||||
payload = await gen.generate_or_get_cached(session_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Escalation package preview failed for session %s", session_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Escalation-package generator error ({type(e).__name__})",
|
||||
)
|
||||
return ResolutionNotePreviewResponse(**payload)
|
||||
|
||||
|
||||
# ── Phase 4: Resolve & post ────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/resolution-note/post",
|
||||
response_model=ResolutionPostResponse,
|
||||
)
|
||||
async def post_resolution_note(
|
||||
session_id: UUID,
|
||||
body: ResolutionNotePostRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> ResolutionPostResponse:
|
||||
"""Commit the engineer-edited resolution note and close the session.
|
||||
|
||||
Three outcomes:
|
||||
- **External post + status verified** — session.status='resolved',
|
||||
markdown + external_id + posted_at persisted, CW status flipped to
|
||||
the configured Resolved status ID and re-fetch-verified.
|
||||
- **External post only** — markdown posted, but no cw_resolved_status_id
|
||||
configured → session.status='resolved', `status_transition_skipped_reason`
|
||||
explains the skip. Not an error — posting the note is meaningful.
|
||||
- **Local-only** — session has no linked PSA ticket → markdown stored on
|
||||
`resolution_note_markdown`, session.status='resolved', outcome =
|
||||
'resolved_local'. No external call.
|
||||
|
||||
Status verification failure raises 502: the engineer intended to close
|
||||
the ticket but we cannot confirm it actually closed. Surfacing silent
|
||||
success would be a footgun.
|
||||
"""
|
||||
session_obj = await _load_session_or_404(db, session_id)
|
||||
if session_obj.status not in ("active", "paused", "requesting_escalation", "escalated"):
|
||||
# Already-resolved sessions shouldn't be re-posted; caller should
|
||||
# query first. escalated→resolved is allowed (engineer revised course).
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Session is already {session_obj.status}",
|
||||
)
|
||||
|
||||
service = PSAWritebackService(db)
|
||||
summary = (body.resolution_summary or body.markdown.strip().splitlines()[0])[:500]
|
||||
|
||||
# Local-only path — no PSA ticket linked, nothing to post.
|
||||
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
||||
session_obj.resolution_note_markdown = body.markdown.strip()
|
||||
session_obj.status = "resolved"
|
||||
session_obj.resolved_at = datetime.now(timezone.utc)
|
||||
session_obj.resolution_summary = summary
|
||||
await db.commit()
|
||||
return ResolutionPostResponse(
|
||||
outcome="resolved_local",
|
||||
session_status=session_obj.status,
|
||||
)
|
||||
|
||||
try:
|
||||
posted = await service.post_resolution_note(session_obj, body.markdown)
|
||||
except Exception as e:
|
||||
logger.exception("post_resolution_note failed for session %s", session_id)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"PSA post failed ({type(e).__name__})",
|
||||
)
|
||||
|
||||
# Attempt the status transition if configured; failed verification is
|
||||
# surfaced loudly (status_code 502) per the ConnectWise anti-silent-
|
||||
# success principle. Not configured → skip with a reason, not an error.
|
||||
target_status_id = await service.resolved_status_id_for_account(session_obj.account_id)
|
||||
verified_status_id: int | None = None
|
||||
verified_status_name: str | None = None
|
||||
skipped_reason: str | None = None
|
||||
if target_status_id is None:
|
||||
skipped_reason = (
|
||||
"No cw_resolved_status_id configured in account_settings.preferences — "
|
||||
"note posted, status unchanged."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result = await service.transition_ticket_status(session_obj, target_status_id)
|
||||
verified_status_id = result["verified_status_id"]
|
||||
verified_status_name = result["verified_status_name"]
|
||||
except PSAStatusVerificationError as e:
|
||||
logger.error("Status verification failed for session %s: %s", session_id, e)
|
||||
# Note was already posted — roll that partial side effect back in
|
||||
# the session record (the CW note itself can't be un-posted).
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Status transition failed for session %s", session_id)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"PSA status transition error ({type(e).__name__})",
|
||||
)
|
||||
|
||||
session_obj.status = "resolved"
|
||||
session_obj.resolved_at = datetime.now(timezone.utc)
|
||||
session_obj.resolution_summary = summary
|
||||
await db.commit()
|
||||
|
||||
return ResolutionPostResponse(
|
||||
outcome="resolved",
|
||||
session_status=session_obj.status,
|
||||
external_id=posted["external_id"],
|
||||
posted_at=posted["posted_at"],
|
||||
verified_status_id=verified_status_id,
|
||||
verified_status_name=verified_status_name,
|
||||
status_transition_skipped_reason=skipped_reason,
|
||||
)
|
||||
|
||||
|
||||
# ── Phase 4: Escalate & post ──────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/escalation-package/post",
|
||||
response_model=ResolutionPostResponse,
|
||||
)
|
||||
async def post_escalation_package(
|
||||
session_id: UUID,
|
||||
body: EscalationPackagePostRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> ResolutionPostResponse:
|
||||
"""Commit the engineer-edited escalation package and mark the session escalated.
|
||||
|
||||
Structure mirrors post_resolution_note:
|
||||
- Local-only when no PSA ticket: markdown stored, session.status='escalated'.
|
||||
- PSA post: internal-analysis note (handoff is for the next engineer,
|
||||
not the customer), optional status transition via cw_escalated_status_id,
|
||||
re-fetch verified.
|
||||
"""
|
||||
session_obj = await _load_session_or_404(db, session_id)
|
||||
if session_obj.status not in ("active", "paused", "resolved"):
|
||||
# resolved→escalated is allowed (engineer realized they need help
|
||||
# after closing); escalated→escalated would be a no-op, block it.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Session is already {session_obj.status}",
|
||||
)
|
||||
|
||||
service = PSAWritebackService(db)
|
||||
reason = body.escalation_reason or body.markdown.strip().splitlines()[0][:500]
|
||||
|
||||
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
||||
session_obj.escalation_package_markdown = body.markdown.strip()
|
||||
session_obj.status = "escalated"
|
||||
session_obj.escalation_reason = reason
|
||||
await db.commit()
|
||||
return ResolutionPostResponse(
|
||||
outcome="escalated_local",
|
||||
session_status=session_obj.status,
|
||||
)
|
||||
|
||||
try:
|
||||
posted = await service.post_escalation_package(session_obj, body.markdown)
|
||||
except Exception as e:
|
||||
logger.exception("post_escalation_package failed for session %s", session_id)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"PSA post failed ({type(e).__name__})",
|
||||
)
|
||||
|
||||
target_status_id = await service.escalated_status_id_for_account(session_obj.account_id)
|
||||
verified_status_id: int | None = None
|
||||
verified_status_name: str | None = None
|
||||
skipped_reason: str | None = None
|
||||
if target_status_id is None:
|
||||
skipped_reason = (
|
||||
"No cw_escalated_status_id configured — package posted, status unchanged."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result = await service.transition_ticket_status(session_obj, target_status_id)
|
||||
verified_status_id = result["verified_status_id"]
|
||||
verified_status_name = result["verified_status_name"]
|
||||
except PSAStatusVerificationError as e:
|
||||
logger.error("Status verification failed for session %s: %s", session_id, e)
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Status transition failed for session %s", session_id)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"PSA status transition error ({type(e).__name__})",
|
||||
)
|
||||
|
||||
session_obj.status = "escalated"
|
||||
session_obj.escalation_reason = reason
|
||||
await db.commit()
|
||||
|
||||
return ResolutionPostResponse(
|
||||
outcome="escalated",
|
||||
session_status=session_obj.status,
|
||||
external_id=posted["external_id"],
|
||||
posted_at=posted["posted_at"],
|
||||
verified_status_id=verified_status_id,
|
||||
verified_status_name=verified_status_name,
|
||||
status_transition_skipped_reason=skipped_reason,
|
||||
)
|
||||
|
||||
|
||||
# ── Helper used by tests ───────────────────────────────────────────────────
|
||||
|
||||
def _clear_preview_cache_for_tests() -> None:
|
||||
"""Reset the singleton cache between tests."""
|
||||
preview_cache._store.clear() # noqa: SLF001 — test-only access
|
||||
@@ -20,7 +20,6 @@ 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:
|
||||
@@ -207,7 +206,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
||||
return None
|
||||
|
||||
|
||||
@public_router.get("/share/{share_token}", response_model=SharePublicView)
|
||||
@router.get("/share/{share_token}", response_model=SharePublicView)
|
||||
@limiter.limit("30/minute")
|
||||
async def access_share(
|
||||
share_token: str,
|
||||
|
||||
@@ -161,7 +161,7 @@ async def get_sidebar_stats(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
esc_scope,
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
AISession.status == "requesting_escalation",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.billing import BillingService
|
||||
from app.core.stripe_handlers import WEBHOOK_HANDLERS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,36 +14,49 @@ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
@router.post("/stripe")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_admin_db),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stripe webhook handler. Public endpoint; signature verification is the
|
||||
only gate. Idempotency via stripe_events table.
|
||||
"""Handle Stripe webhook events.
|
||||
|
||||
Returns 200 even when Stripe is not configured — keeps the receiver
|
||||
permissive for local dev.
|
||||
Returns 200 for all events to prevent Stripe retries.
|
||||
Actual processing happens only when Stripe is configured.
|
||||
"""
|
||||
if not settings.stripe_enabled or not settings.STRIPE_WEBHOOK_SECRET:
|
||||
if not settings.stripe_enabled:
|
||||
return {"status": "ok", "message": "Stripe not configured, event ignored"}
|
||||
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
if not sig_header:
|
||||
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
|
||||
|
||||
if not sig_header:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing stripe-signature header"
|
||||
)
|
||||
|
||||
# Verify webhook signature
|
||||
try:
|
||||
import stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("stripe package not installed, cannot verify webhook")
|
||||
return {"status": "ok", "message": "stripe package not installed"}
|
||||
except Exception as e:
|
||||
logger.warning("stripe webhook bad signature: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
logger.error("Stripe webhook signature verification failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid signature"
|
||||
)
|
||||
|
||||
applied = await BillingService.apply_subscription_event(
|
||||
db,
|
||||
event_id=event["id"],
|
||||
event_type=event["type"],
|
||||
payload={"data": event["data"]},
|
||||
)
|
||||
return {"status": "ok", "applied": applied}
|
||||
event_type = event.get("type", "")
|
||||
handler = WEBHOOK_HANDLERS.get(event_type)
|
||||
|
||||
if handler:
|
||||
try:
|
||||
await handler(event, db)
|
||||
except Exception:
|
||||
logger.exception("Error handling Stripe event %s", event_type)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import (
|
||||
require_tenant_context,
|
||||
require_active_subscription,
|
||||
require_verified_email_after_grace,
|
||||
)
|
||||
from app.api.deps import require_tenant_context
|
||||
from app.api.endpoints import (
|
||||
admin,
|
||||
admin_audit,
|
||||
@@ -23,14 +19,11 @@ from app.api.endpoints import (
|
||||
analytics,
|
||||
assistant_chat,
|
||||
auth,
|
||||
billing,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
branding,
|
||||
categories,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
@@ -39,19 +32,15 @@ from app.api.endpoints import (
|
||||
invite,
|
||||
kb_accelerator,
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
script_builder,
|
||||
session_branches,
|
||||
session_facts,
|
||||
session_handoffs,
|
||||
session_resolutions,
|
||||
session_suggested_fixes,
|
||||
sessions,
|
||||
shared,
|
||||
shares,
|
||||
@@ -83,14 +72,10 @@ api_router = APIRouter()
|
||||
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
|
||||
# ---------------------------------------------------------------------------
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(oauth_endpoints.router)
|
||||
api_router.include_router(billing.router) # Reachable when subscription locked
|
||||
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
|
||||
@@ -108,38 +93,26 @@ api_router.include_router(admin_settings.router)
|
||||
api_router.include_router(admin_categories.router)
|
||||
api_router.include_router(admin_survey.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-facing endpoints — tenant context required
|
||||
#
|
||||
# _tenant_deps: routers that only require an authenticated user inside a
|
||||
# tenant (auth/account/admin/non-Pro feature surfaces).
|
||||
# _pro_deps: routers gated behind an active Pro subscription. Adds
|
||||
# require_active_subscription which raises 402 unless the
|
||||
# account's Subscription is active/complimentary/past_due or
|
||||
# trialing-with-time-remaining. Allowlisted paths in deps.py
|
||||
# bypass the gate for billing/account admin/auth flows.
|
||||
# ---------------------------------------------------------------------------
|
||||
_tenant_deps = [Depends(require_tenant_context)]
|
||||
_pro_deps = [
|
||||
Depends(require_tenant_context),
|
||||
Depends(require_active_subscription),
|
||||
Depends(require_verified_email_after_grace),
|
||||
]
|
||||
|
||||
api_router.include_router(trees.router, dependencies=_pro_deps)
|
||||
api_router.include_router(trees.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sessions.router, dependencies=_pro_deps)
|
||||
api_router.include_router(sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(invite.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tags.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(folders.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(step_categories.router, dependencies=_pro_deps)
|
||||
api_router.include_router(steps.router, dependencies=_pro_deps)
|
||||
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(steps.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(accounts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(shares.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ratings.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(analytics.router, dependencies=_pro_deps)
|
||||
api_router.include_router(analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(feedback.router, dependencies=_tenant_deps)
|
||||
@@ -147,31 +120,25 @@ api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
|
||||
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=_pro_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)
|
||||
api_router.include_router(scripts.router, dependencies=_pro_deps)
|
||||
api_router.include_router(integrations.router, dependencies=_pro_deps)
|
||||
api_router.include_router(scripts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(integrations.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(branding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
|
||||
# session_handoffs queue router must come before ai_sessions to avoid conflict
|
||||
api_router.include_router(session_handoffs.queue_router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_resolutions.router, dependencies=_pro_deps)
|
||||
# session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions
|
||||
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
|
||||
api_router.include_router(session_facts.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_suggested_fixes.router, dependencies=_pro_deps)
|
||||
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_pro_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_pro_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(notifications.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(uploads.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(script_builder.router, dependencies=_pro_deps)
|
||||
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_pro_deps)
|
||||
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
||||
|
||||
@@ -40,7 +40,7 @@ CRITICAL BEHAVIORS:
|
||||
- Act as a senior engineer, not a chatbot. Use your domain knowledge to SUGGEST diagnostic steps, not just record what the user says.
|
||||
- When the user describes a problem area, demonstrate understanding by naming specific sub-categories, common causes, and relevant tools.
|
||||
- Challenge assumptions constructively: "Before we go down that path, have you considered checking X first? In my experience, that resolves 60% of these cases."
|
||||
- Capture SPECIFIC commands with exact syntax (PowerShell/CLI invocations the engineer would actually paste into a shell), not vague directives like "check the service".
|
||||
- Capture SPECIFIC commands with exact syntax. Not "check the service" but "Get-Service ADSync | Select-Object Status, StartType".
|
||||
- Include expected outcomes for every action: what does success look like?
|
||||
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
|
||||
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
|
||||
@@ -74,7 +74,7 @@ STRUCTURAL RULES:
|
||||
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
|
||||
|
||||
CROSS-REFERENCE / LOOP-BACK PATTERN:
|
||||
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID — including ancestor decision nodes for re-verification loops. The target ID must already exist somewhere in the tree.
|
||||
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
|
||||
"""
|
||||
|
||||
INTERVIEW_PROTOCOL = """
|
||||
@@ -85,7 +85,7 @@ Ask broad questions to understand the problem domain and scope:
|
||||
- What type of issue is this flow for?
|
||||
- Who is the target audience? (Tier 1 help desk, Tier 2, Tier 3?)
|
||||
- What environment assumptions? (On-prem, hybrid, specific vendors?)
|
||||
Demonstrate domain expertise immediately. When the user names a technology, ask a follow-up that proves you know its common failure modes — a sub-categorization question that only someone fluent in that area would think to ask. Use vocabulary native to whatever the user actually mentioned, not stock examples from past conversations.
|
||||
Demonstrate domain expertise immediately. If the user says "Azure AD Sync failures," show understanding: "Are you primarily seeing password hash sync issues, object attribute sync failures, or full directory sync errors?"
|
||||
DO NOT emit [TREE_UPDATE] during scoping. You are still understanding the problem.
|
||||
|
||||
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||
@@ -130,7 +130,7 @@ Your response is natural conversational text. When the tree structure changes, i
|
||||
|
||||
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||
[METADATA]
|
||||
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||
{"name": "...", "description": "...", "tags": ["..."]}
|
||||
[/METADATA]
|
||||
|
||||
IMPORTANT:
|
||||
@@ -172,8 +172,8 @@ STRUCTURAL RULES:
|
||||
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
|
||||
- The last step MUST be type "procedure_end"
|
||||
- Use section_headers to organize steps into logical phases
|
||||
- Commands are arrays of objects: [{"code": "<exact command>", "label": "<short label>", "language": "powershell|bash|cmd"}]
|
||||
- Descriptions support [VAR:variable_name] interpolation for intake form variables. Pick variable names that fit the procedure being built — do not reuse names from prior conversations.
|
||||
- Commands are arrays of objects: [{"code": "Get-Service ADSync", "label": "Check sync service", "language": "powershell"}]
|
||||
- Descriptions support [VAR:variable_name] interpolation for intake form variables (e.g., "Connect to [VAR:server_name] via RDP")
|
||||
|
||||
VARIABLE INTERPOLATION:
|
||||
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
|
||||
@@ -188,7 +188,7 @@ Understand the process being documented:
|
||||
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
|
||||
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
|
||||
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
|
||||
Demonstrate domain expertise: when the user names a process, ask a sub-categorization question that distinguishes which variant of that process they mean (the variants will differ by technology — use vocabulary specific to whatever the user mentioned, not examples from prior chats).
|
||||
Demonstrate domain expertise: if the user says "Exchange Online mailbox migration," show understanding: "Are we covering full tenant-to-tenant migration, on-prem to Exchange Online cutover, or individual mailbox moves with hybrid?"
|
||||
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
|
||||
|
||||
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||
@@ -238,12 +238,12 @@ Your response is natural conversational text. When the step structure changes, i
|
||||
|
||||
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||
[METADATA]
|
||||
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||
{"name": "...", "description": "...", "tags": ["..."]}
|
||||
[/METADATA]
|
||||
|
||||
4. Intake form suggestion (when intake form fields are identified):
|
||||
[INTAKE_FORM]
|
||||
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
|
||||
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||
[/INTAKE_FORM]
|
||||
|
||||
IMPORTANT:
|
||||
@@ -659,12 +659,12 @@ Requirements:
|
||||
|
||||
Also provide metadata as a separate JSON object after the steps:
|
||||
[METADATA]
|
||||
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||
{"name": "...", "description": "...", "tags": ["..."]}
|
||||
[/METADATA]
|
||||
|
||||
If we discussed intake form fields, also include:
|
||||
[INTAKE_FORM]
|
||||
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
|
||||
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||
[/INTAKE_FORM]"""
|
||||
else:
|
||||
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||
@@ -681,7 +681,7 @@ Requirements:
|
||||
|
||||
Also provide metadata as a separate JSON object after the tree:
|
||||
[METADATA]
|
||||
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||
{"name": "...", "description": "...", "tags": ["..."]}
|
||||
[/METADATA]"""
|
||||
|
||||
provider_messages.append({"role": "user", "content": generation_instruction})
|
||||
|
||||
@@ -199,10 +199,7 @@ async def generate_fixes(
|
||||
|
||||
try:
|
||||
text, in_tok, out_tok = await provider.generate_json(
|
||||
system_prompt=[
|
||||
{"type": "text", "text": FIX_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant across all fix attempts
|
||||
],
|
||||
system_prompt=FIX_SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
@@ -235,11 +232,7 @@ async def generate_fixes(
|
||||
|
||||
try:
|
||||
text2, in_tok2, out_tok2 = await provider.generate_json(
|
||||
system_prompt=[
|
||||
{"type": "text", "text": FIX_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant; retry reads the cached
|
||||
# system block from the first attempt above
|
||||
],
|
||||
system_prompt=FIX_SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
@@ -3,169 +3,16 @@ AI Provider abstraction layer.
|
||||
|
||||
Supports Gemini (google-genai) and Anthropic (anthropic) as interchangeable
|
||||
backends for JSON generation used by the AI Flow Builder.
|
||||
|
||||
## Prompt caching (Anthropic only)
|
||||
|
||||
Callers may pass `system_prompt` as either:
|
||||
|
||||
- `str` — backward-compatible, uncached.
|
||||
- `list[SystemBlock]` — Anthropic structured system blocks. Each block is a
|
||||
dict of shape `{"type": "text", "text": str, "cache_control": {...}?}`.
|
||||
|
||||
Caching policy (policy α, per Phase 0.1 design):
|
||||
- If any block in the list carries an explicit `cache_control` key, that
|
||||
caller-authored configuration is honored verbatim.
|
||||
- If no block carries `cache_control`, the provider applies
|
||||
`cache_control: {"type": "ephemeral"}` to the first block only. First block
|
||||
is the common "large static prefix" case (e.g. system prompt, reference data).
|
||||
|
||||
Gemini ignores cache_control and concatenates list blocks into one system
|
||||
string — callers should not rely on Gemini for cache-hit behavior.
|
||||
|
||||
TODO(phase0-verify): When a dev environment is available, verify cache-hit
|
||||
behavior by hitting any FlowPilot endpoint twice within the 5-minute
|
||||
ephemeral TTL. First call should emit `anthropic.cache` with
|
||||
`cache_creation_input_tokens > 0`; second call with `cache_read_input_tokens > 0`.
|
||||
If the second call returns zero reads, inspect the prefix for silent
|
||||
invalidators (timestamps, unsorted JSON keys, varying tool list ordering).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Anthropic structured system block. See module docstring for caching policy.
|
||||
SystemBlock = dict[str, Any]
|
||||
|
||||
|
||||
def _normalize_system_for_anthropic(
|
||||
system_prompt: str | list[SystemBlock],
|
||||
) -> str | list[SystemBlock]:
|
||||
"""Return the value to pass as the `system=` parameter to the Anthropic API.
|
||||
|
||||
- Plain strings pass through untouched (uncached path).
|
||||
- Lists are returned as structured system blocks. If no block in the list
|
||||
carries an explicit `cache_control`, `cache_control: {"type": "ephemeral"}`
|
||||
is applied to the FIRST block only (policy α).
|
||||
- Caller-authored `cache_control` is never overwritten.
|
||||
"""
|
||||
if isinstance(system_prompt, str):
|
||||
return system_prompt
|
||||
|
||||
if not system_prompt:
|
||||
# Empty list is not a meaningful system prompt — pass empty string so
|
||||
# Anthropic treats this as "no system prompt" rather than erroring.
|
||||
return ""
|
||||
|
||||
blocks = [dict(b) for b in system_prompt]
|
||||
already_cached = any("cache_control" in b for b in blocks)
|
||||
|
||||
if not already_cached:
|
||||
blocks[0]["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _flatten_system_for_gemini(
|
||||
system_prompt: str | list[SystemBlock],
|
||||
) -> str:
|
||||
"""Gemini has no structured system blocks; concatenate list entries."""
|
||||
if isinstance(system_prompt, str):
|
||||
return system_prompt
|
||||
return "\n\n".join(b.get("text", "") for b in system_prompt)
|
||||
|
||||
|
||||
def build_anthropic_chat_messages(
|
||||
history: list[dict[str, Any]],
|
||||
new_message: str,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
format_reminder: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Construct the Anthropic `messages` payload for a cached multi-turn chat.
|
||||
|
||||
Responsibilities:
|
||||
- Copy the valid history messages in order.
|
||||
- Apply `cache_control: ephemeral` to the LAST history message so the entire
|
||||
conversation prefix is cached across turns. The new user message stays
|
||||
uncached (it changes each turn).
|
||||
- Append `format_reminder` to the new user message if provided. The reminder
|
||||
is invisible to storage (caller's concern) but helps enforce structured
|
||||
output compliance at generation time.
|
||||
- If `images` are provided, render the new user message as a multimodal
|
||||
content block list (images first, then text). Otherwise, render it as
|
||||
a plain string.
|
||||
|
||||
This helper is Anthropic-specific: the cache-breakpoint pattern, ephemeral
|
||||
cache_control, and multimodal block shape are all Anthropic conventions.
|
||||
Do not call it from Gemini code paths.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in history:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Cache breakpoint on the last existing history message so the entire
|
||||
# conversation prefix is cached across turns. Safe only when there IS a
|
||||
# history message; otherwise the new message is the only message.
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
messages[-1] = {
|
||||
"role": last["role"],
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": last["content"],
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
effective_text = new_message + (format_reminder or "")
|
||||
|
||||
if images:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
for img in images:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": img["media_type"],
|
||||
"data": img["data"],
|
||||
},
|
||||
}
|
||||
)
|
||||
content_blocks.append({"type": "text", "text": effective_text})
|
||||
messages.append({"role": "user", "content": content_blocks})
|
||||
else:
|
||||
messages.append({"role": "user", "content": effective_text})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def _log_anthropic_cache_usage(usage: Any, model: str) -> None:
|
||||
"""Emit a structured log line capturing cache_read / cache_creation tokens."""
|
||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
||||
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
||||
if cache_read or cache_creation:
|
||||
logger.info(
|
||||
"anthropic.cache",
|
||||
extra={
|
||||
"event": "anthropic.cache",
|
||||
"model": model,
|
||||
"cache_read_input_tokens": cache_read,
|
||||
"cache_creation_input_tokens": cache_creation,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Abstract base class for AI providers."""
|
||||
@@ -173,16 +20,14 @@ class AIProvider(ABC):
|
||||
@abstractmethod
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Generate a JSON response from the AI model.
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instruction. Plain `str` is uncached
|
||||
(Anthropic) or used as-is (Gemini). `list[SystemBlock]` enables
|
||||
Anthropic prompt caching per module-docstring policy.
|
||||
system_prompt: System-level instruction for the model.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
@@ -194,25 +39,37 @@ class AIProvider(ABC):
|
||||
@abstractmethod
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Generate a text response from the AI model (no JSON constraint).
|
||||
|
||||
See `generate_json` for argument semantics.
|
||||
Args:
|
||||
system_prompt: System-level instruction for the model.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
Returns:
|
||||
Tuple of (response_text, input_tokens, output_tokens).
|
||||
"""
|
||||
...
|
||||
|
||||
async def generate_text_stream(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> "AsyncIterator[str]":
|
||||
"""Stream a text response token by token.
|
||||
|
||||
See `generate_json` for argument semantics.
|
||||
Args:
|
||||
system_prompt: System-level instruction for the model.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
|
||||
Yields:
|
||||
Text chunks as they are generated.
|
||||
"""
|
||||
raise NotImplementedError("Streaming not supported for this provider")
|
||||
# Make this an async generator to satisfy type checker
|
||||
@@ -228,15 +85,14 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
client = genai.Client(api_key=self._api_key)
|
||||
system_text = _flatten_system_for_gemini(system_prompt)
|
||||
|
||||
# Convert messages to Gemini Content format
|
||||
contents: list[genai_types.Content] = []
|
||||
@@ -250,7 +106,7 @@ class GeminiProvider(AIProvider):
|
||||
)
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
system_instruction=system_text,
|
||||
system_instruction=system_prompt,
|
||||
max_output_tokens=max_tokens,
|
||||
response_mime_type="application/json",
|
||||
)
|
||||
@@ -281,15 +137,14 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
client = genai.Client(api_key=self._api_key)
|
||||
system_text = _flatten_system_for_gemini(system_prompt)
|
||||
|
||||
contents: list[genai_types.Content] = []
|
||||
for msg in messages:
|
||||
@@ -302,7 +157,7 @@ class GeminiProvider(AIProvider):
|
||||
)
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
system_instruction=system_text,
|
||||
system_instruction=system_prompt,
|
||||
max_output_tokens=max_tokens,
|
||||
# No response_mime_type — allow free-form text
|
||||
)
|
||||
@@ -359,17 +214,16 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
async def generate_json(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
client = _get_anthropic_client(self._api_key, self._timeout)
|
||||
normalized_system = _normalize_system_for_anthropic(system_prompt)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=max_tokens,
|
||||
system=normalized_system,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
@@ -377,14 +231,12 @@ class AnthropicProvider(AIProvider):
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
|
||||
_log_anthropic_cache_usage(response.usage, self._model)
|
||||
|
||||
return text, input_tokens, output_tokens
|
||||
|
||||
async def generate_text(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
# Anthropic doesn't differentiate between JSON and text mode
|
||||
@@ -392,28 +244,20 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
async def generate_text_stream(
|
||||
self,
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
system_prompt: str,
|
||||
messages: list[dict[str, str]],
|
||||
max_tokens: int = 4096,
|
||||
) -> AsyncIterator[str]:
|
||||
client = _get_anthropic_client(self._api_key, self._timeout)
|
||||
normalized_system = _normalize_system_for_anthropic(system_prompt)
|
||||
|
||||
async with client.messages.stream(
|
||||
model=self._model,
|
||||
max_tokens=max_tokens,
|
||||
system=normalized_system,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield text
|
||||
# Per Anthropic SDK, get_final_message() resolves the stream's
|
||||
# final usage object (including cache_read/cache_creation tokens).
|
||||
try:
|
||||
final = await stream.get_final_message()
|
||||
_log_anthropic_cache_usage(final.usage, self._model)
|
||||
except Exception as exc: # best-effort telemetry, never fail the stream
|
||||
logger.debug("anthropic.cache streaming usage unavailable: %s", exc)
|
||||
|
||||
|
||||
def get_ai_provider(model: str | None = None) -> AIProvider:
|
||||
|
||||
@@ -89,10 +89,8 @@ Additional rules:
|
||||
5. Use unique node IDs prefixed with the branch context (e.g., "gpo-check-link")
|
||||
6. Build the tree bottom-up in your head: create solution/leaf nodes first, then build parent nodes referencing their IDs
|
||||
|
||||
SHAPE-ONLY schema example (do not copy this content verbatim — it shows
|
||||
how IDs link, NOT what to ask or run; your real tree must reflect the
|
||||
branch the user described):
|
||||
{"id": "<root-slug>", "type": "decision", "question": "<diagnostic question for THIS branch>", "help_text": "<optional hint>", "options": [{"id": "<opt-1>", "label": "<observable answer 1>", "next_node_id": "<child-1>"}, {"id": "<opt-2>", "label": "<observable answer 2>", "next_node_id": "<child-2>"}], "children": [{"id": "<child-1>", "type": "action", "title": "<what to do>", "description": "<details>", "commands": ["<exact command for THIS branch>"], "expected_outcome": "<what success looks like>", "next_node_id": "<sibling-id>"}, {"id": "<sibling-id>", "type": "solution", "title": "<resolution title>", "description": "<resolution description>", "resolution_steps": ["<step 1>", "<step 2>"]}, {"id": "<child-2>", "type": "solution", "title": "<other resolution>", "description": "<...>", "resolution_steps": ["<step 1>"]}]}"""
|
||||
Few-shot example showing correct action node next_node_id usage:
|
||||
{"id": "dns-root", "type": "decision", "question": "Can the client resolve any DNS names?", "help_text": "Run: nslookup google.com", "options": [{"id": "dns-opt-none", "label": "No — nslookup times out or returns 'server failed'", "next_node_id": "dns-check-service"}, {"id": "dns-opt-partial", "label": "Some names resolve but others fail", "next_node_id": "dns-check-specific"}], "children": [{"id": "dns-check-service", "type": "action", "title": "Check DNS Client Service", "description": "Verify the DNS Client service is running on the affected machine", "commands": ["Get-Service -Name Dnscache | Select-Object Status,StartType"], "expected_outcome": "Status should be Running", "next_node_id": "dns-service-solution"}, {"id": "dns-service-solution", "type": "solution", "title": "DNS Service Was Stopped", "description": "The DNS Client service was stopped, preventing all name resolution", "resolution_steps": ["Run: Start-Service Dnscache", "Set startup type: Set-Service Dnscache -StartupType Automatic", "Flush cache: ipconfig /flushdns", "Test: nslookup google.com"]}, {"id": "dns-check-specific", "type": "solution", "title": "Selective DNS Failure — Stale or Missing Records", "description": "Some records resolve correctly, indicating DNS is functional but specific records are stale or missing", "resolution_steps": ["Check DNS server for missing A/CNAME records", "Clear DNS cache on the DNS server: Clear-DnsServerCache", "Flush client cache: ipconfig /flushdns", "Verify with: nslookup <failing-hostname>"]}]}"""
|
||||
|
||||
|
||||
CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlow's tree schema.
|
||||
@@ -148,10 +146,7 @@ async def scaffold_branches(
|
||||
user_message += f"Environment: {', '.join(tags)}\n"
|
||||
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=[
|
||||
{"type": "text", "text": SCAFFOLD_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant across all scaffold calls
|
||||
],
|
||||
system_prompt=SCAFFOLD_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=2048,
|
||||
)
|
||||
@@ -212,13 +207,7 @@ async def generate_branch_detail(
|
||||
|
||||
for attempt in range(3):
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=[
|
||||
{"type": "text", "text": BRANCH_DETAIL_SYSTEM_PROMPT},
|
||||
# cacheable: stable constant. Retries in this loop re-read the
|
||||
# cached system block rather than paying full input cost each
|
||||
# attempt — the ~2.5k-token prompt with few-shot example is
|
||||
# the dominant cost here.
|
||||
],
|
||||
system_prompt=BRANCH_DETAIL_SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
@@ -94,12 +94,11 @@ class Settings(BaseSettings):
|
||||
STRIPE_SECRET_KEY: Optional[str] = None
|
||||
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
|
||||
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
||||
SELF_SERVE_ENABLED: bool = False
|
||||
|
||||
@property
|
||||
def stripe_enabled(self) -> bool:
|
||||
"""Check if Stripe is configured."""
|
||||
return bool(self.STRIPE_SECRET_KEY)
|
||||
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
|
||||
|
||||
# AI Flow Builder
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
@@ -112,16 +111,6 @@ 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] = {
|
||||
@@ -139,24 +128,6 @@ class Settings(BaseSettings):
|
||||
"variable_inference": "fast",
|
||||
"kb_convert": "standard",
|
||||
"script_build": "standard",
|
||||
"network_diagram_generate": "standard",
|
||||
# FlowPilot migration Phase 2 — short, latency-sensitive transformation
|
||||
# of an engineer's answer/check output into a candidate fact.
|
||||
# Doc Section 6.6 sets Haiku as the default; instrumentation tracks
|
||||
# disputed_fact_rate so we can escalate to Sonnet if quality drops.
|
||||
"fact_synthesis": "fast",
|
||||
# FlowPilot migration Phase 3 — resolution-note preview that ships to
|
||||
# the customer ticket. Sonnet because customer-facing artifact quality
|
||||
# matters more than latency; the in-process state_version cache keeps
|
||||
# cost manageable.
|
||||
"resolution_note": "standard",
|
||||
# FlowPilot migration Phase 4 — escalation handoff package. Parallel
|
||||
# to resolution_note: Sonnet, same cache story, no MCP.
|
||||
"escalation_package": "standard",
|
||||
# FlowPilot migration Phase 5 — extract a parameter schema from a
|
||||
# concrete rendered script so a draft_template can be proposed.
|
||||
# Creates a persistent library artifact on accept, so Sonnet.
|
||||
"template_extraction": "standard",
|
||||
}
|
||||
|
||||
def get_model_for_action(self, action_type: str) -> str:
|
||||
@@ -194,13 +165,6 @@ class Settings(BaseSettings):
|
||||
"""Check if ConnectWise integration is configured."""
|
||||
return self.CW_CLIENT_ID is not None
|
||||
|
||||
# OAuth providers (self-serve signup)
|
||||
GOOGLE_CLIENT_ID: Optional[str] = None
|
||||
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||||
MS_CLIENT_ID: Optional[str] = None
|
||||
MS_CLIENT_SECRET: Optional[str] = None
|
||||
OAUTH_REDIRECT_BASE: str = "http://localhost:5173"
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: Optional[str] = None
|
||||
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""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()
|
||||
@@ -153,29 +153,48 @@ Identify values that would change between executions (server names, IPs, usernam
|
||||
|
||||
## Output Format
|
||||
|
||||
Return a JSON object with this SHAPE (DO NOT copy the placeholders below
|
||||
verbatim — fill each field with content derived from the actual KB article
|
||||
the engineer attached, NOT from this schema):
|
||||
Return a JSON object:
|
||||
```json
|
||||
{
|
||||
"title": "<procedure title derived from the article>",
|
||||
"description": "<brief description of what this procedure accomplishes>",
|
||||
"title": "Procedure title derived from the article",
|
||||
"description": "Brief description of what this procedure accomplishes",
|
||||
"steps": [
|
||||
{
|
||||
"id": "<unique-kebab-case-id>",
|
||||
"type": "step|warning|section_header",
|
||||
"content": "<step body — may include [VAR:<your_variable>] interpolation>",
|
||||
"confidence": <float 0.0-1.0>,
|
||||
"source_excerpt": "<the verbatim sentence/phrase from the article that this step came from>"
|
||||
"id": "unique-step-id",
|
||||
"type": "step",
|
||||
"content": "Open Server Manager and navigate to Add Roles on [VAR:server_name]",
|
||||
"confidence": 0.95,
|
||||
"source_excerpt": "Step 1: Open Server Manager on DC01..."
|
||||
},
|
||||
{
|
||||
"id": "warning-dns",
|
||||
"type": "warning",
|
||||
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
|
||||
"confidence": 0.90,
|
||||
"source_excerpt": "Note: Restarting DNS will cause a brief outage"
|
||||
},
|
||||
{
|
||||
"id": "section-verification",
|
||||
"type": "section_header",
|
||||
"content": "Verification Steps",
|
||||
"confidence": 1.0,
|
||||
"source_excerpt": "Verification"
|
||||
}
|
||||
],
|
||||
"intake_form": [
|
||||
{
|
||||
"variable_name": "<snake_case_name fitting THIS procedure>",
|
||||
"label": "<Human Label>",
|
||||
"field_type": "text|password|select|textarea|number|boolean",
|
||||
"required": true|false,
|
||||
"display_order": <integer>
|
||||
"variable_name": "server_name",
|
||||
"label": "Server Name",
|
||||
"field_type": "text",
|
||||
"required": true,
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"variable_name": "ip_address",
|
||||
"label": "IP Address",
|
||||
"field_type": "text",
|
||||
"required": true,
|
||||
"display_order": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -406,12 +425,7 @@ async def convert_document(
|
||||
|
||||
try:
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=[
|
||||
{"type": "text", "text": system_prompt},
|
||||
# cacheable: one of two stable constants (TROUBLESHOOTING_SYSTEM_PROMPT
|
||||
# or PROCEDURAL_SYSTEM_PROMPT) selected by target_type. Each
|
||||
# variant caches independently by text content.
|
||||
],
|
||||
system_prompt=system_prompt,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=16384,
|
||||
)
|
||||
|
||||
@@ -56,16 +56,6 @@ from .session_handoff import SessionHandoff
|
||||
from .session_resolution_output import SessionResolutionOutput
|
||||
from .template_tree import TemplateTree
|
||||
from .platform_step import PlatformStep
|
||||
from .device_type import DeviceType
|
||||
from .network_diagram import NetworkDiagram
|
||||
from .session_fact import SessionFact
|
||||
from .session_suggested_fix import SessionSuggestedFix
|
||||
from .draft_template import DraftTemplate
|
||||
from .account_settings import AccountSettings
|
||||
from .oauth_identity import OAuthIdentity # noqa: F401
|
||||
from .plan_billing import PlanBilling # noqa: F401
|
||||
from .sales_lead import SalesLead # noqa: F401
|
||||
from .stripe_event import StripeEvent # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -136,14 +126,4 @@ __all__ = [
|
||||
"SessionResolutionOutput",
|
||||
"TemplateTree",
|
||||
"PlatformStep",
|
||||
"DeviceType",
|
||||
"NetworkDiagram",
|
||||
"SessionFact",
|
||||
"SessionSuggestedFix",
|
||||
"DraftTemplate",
|
||||
"AccountSettings",
|
||||
"OAuthIdentity",
|
||||
"PlanBilling",
|
||||
"SalesLead",
|
||||
"StripeEvent",
|
||||
]
|
||||
|
||||
@@ -48,8 +48,6 @@ class Account(Base):
|
||||
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
|
||||
branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# SSO / SAML groundwork (Task 11)
|
||||
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
@@ -27,8 +27,6 @@ class AccountInvite(Base):
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
@@ -39,10 +37,6 @@ class AccountInvite(Base):
|
||||
def is_used(self) -> bool:
|
||||
return self.accepted_by_id is not None
|
||||
|
||||
@property
|
||||
def is_revoked(self) -> bool:
|
||||
return self.revoked_at is not None
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
if self.expires_at is None:
|
||||
@@ -51,4 +45,4 @@ class AccountInvite(Base):
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return not self.is_used and not self.is_expired and not self.is_revoked
|
||||
return not self.is_used and not self.is_expired
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Per-account settings with a JSONB preferences grab-bag.
|
||||
|
||||
Rows are created lazily on first write. Reads of a missing row return the
|
||||
caller-supplied default — no upfront row creation per account.
|
||||
|
||||
Settings live in `preferences` until they meet the promotion criteria in
|
||||
Section 4.6 of FLOWPILOT-MIGRATION.md (hot path / validation / joins), at
|
||||
which point a future migration adds a typed column and the helpers prefer it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import select
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
class AccountSettings(Base):
|
||||
"""One row per account. Created lazily on first `set_setting` call."""
|
||||
__tablename__ = "account_settings"
|
||||
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
|
||||
@classmethod
|
||||
async def get_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
key: str,
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Return preferences[key] for the account, or `default` if no row/no key.
|
||||
|
||||
Never creates a row — this is the pure-read path.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(cls.preferences).where(cls.account_id == account_id)
|
||||
)
|
||||
prefs = result.scalar_one_or_none()
|
||||
if prefs is None:
|
||||
return default
|
||||
return prefs.get(key, default)
|
||||
|
||||
@classmethod
|
||||
async def set_setting(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
account_id: uuid.UUID,
|
||||
key: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""Upsert preferences[key] = value for the account.
|
||||
|
||||
Creates the row on first write; on subsequent writes, merges the key
|
||||
into the existing preferences JSON without clobbering other keys.
|
||||
Uses PostgreSQL's `||` jsonb merge operator via ON CONFLICT DO UPDATE.
|
||||
"""
|
||||
stmt = pg_insert(cls).values(
|
||||
account_id=account_id,
|
||||
preferences={key: value},
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[cls.account_id],
|
||||
set_={
|
||||
# Merge the new {key: value} into the existing preferences.
|
||||
# The `||` operator on jsonb overwrites matching keys and keeps
|
||||
# all other keys intact.
|
||||
"preferences": cls.preferences.op("||")(stmt.excluded.preferences),
|
||||
"updated_at": text("now()"),
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
@@ -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, TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -46,7 +46,6 @@ 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(
|
||||
@@ -151,18 +150,6 @@ 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",
|
||||
@@ -227,38 +214,6 @@ class AISession(Base):
|
||||
comment="Current task lane state: {questions: [...], actions: [...]}",
|
||||
)
|
||||
|
||||
# ── Resolution / Escalation artifacts (Phase 1 — FlowPilot migration) ──
|
||||
# Markdown of the posted note + PSA external ID for round-trip traceability.
|
||||
resolution_note_markdown: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Final Resolve note markdown, as posted to the PSA",
|
||||
)
|
||||
resolution_note_posted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
resolution_note_external_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), nullable=True,
|
||||
comment="PSA (e.g. CW) ticket-note ID returned at post time",
|
||||
)
|
||||
escalation_package_markdown: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Final Escalate handoff package markdown, as posted to the PSA",
|
||||
)
|
||||
escalation_package_posted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
escalation_package_external_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(128), nullable=True,
|
||||
comment="PSA ticket-note ID for the escalation package",
|
||||
)
|
||||
# Incremented atomically by any write that invalidates the resolution
|
||||
# note preview cache (facts, suggested fixes, script generations).
|
||||
# See FLOWPILOT-MIGRATION.md Section 5.5.
|
||||
state_version: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default=sa.text("0"),
|
||||
comment="Monotonic preview-cache version; bumped on state-changing writes",
|
||||
)
|
||||
|
||||
# ── Branching ──
|
||||
is_branching: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Device type model for network diagrams."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DeviceType(Base):
|
||||
"""A device type for network diagram nodes (platform or account-custom)."""
|
||||
__tablename__ = "device_types"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False,
|
||||
comment="Unique identifier used in diagram node data",
|
||||
)
|
||||
label: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False,
|
||||
comment="Display name",
|
||||
)
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False,
|
||||
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
|
||||
)
|
||||
is_system: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
comment="True for built-in types that cannot be deleted",
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="Platform account for system types, tenant account for custom types",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Display order within category",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Draft template model — scripts generated during a session, pending templatization.
|
||||
|
||||
Created when an engineer picks "Run now, templatize after resolve" in the
|
||||
three-option dialog. Post-resolve, the TemplatizePrompt component reads pending
|
||||
drafts and lets the engineer accept (promotes to `script_templates`) or reject.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Text, DateTime, ForeignKey, String, CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
|
||||
|
||||
class DraftTemplate(Base):
|
||||
"""A session-generated script pending conversion to a reusable template."""
|
||||
__tablename__ = "draft_templates"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_draft_templates_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
source_session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id"),
|
||||
nullable=False,
|
||||
)
|
||||
source_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
script_body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
proposed_parameters: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False
|
||||
)
|
||||
proposed_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
proposed_category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_categories.id"),
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="pending"
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Set when status transitions to 'accepted' and the draft is promoted
|
||||
# to a real script_templates row.
|
||||
promoted_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
source_session: Mapped["AISession"] = relationship(
|
||||
"AISession", foreign_keys=[source_session_id]
|
||||
)
|
||||
source_user: Mapped["User"] = relationship("User", foreign_keys=[source_user_id])
|
||||
proposed_category: Mapped["ScriptCategory | None"] = relationship(
|
||||
"ScriptCategory", foreign_keys=[proposed_category_id]
|
||||
)
|
||||
promoted_template: Mapped["ScriptTemplate | None"] = relationship(
|
||||
"ScriptTemplate", foreign_keys=[promoted_template_id]
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Network diagram model."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class NetworkDiagram(Base):
|
||||
"""A network topology diagram scoped to one account."""
|
||||
__tablename__ = "network_diagrams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_archived: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
creator: Mapped["User | None"] = relationship("User", foreign_keys=[created_by])
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class OAuthIdentity(Base):
|
||||
__tablename__ = "oauth_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
|
||||
Index("ix_oauth_identities_user_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
provider_subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_email_at_link: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship("User", backref="oauth_identities")
|
||||
@@ -1,31 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PlanBilling(Base):
|
||||
__tablename__ = "plan_billing"
|
||||
|
||||
plan: Mapped[str] = mapped_column(
|
||||
String(50), ForeignKey("plan_limits.plan"), primary_key=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
monthly_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
annual_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
stripe_product_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
stripe_monthly_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
stripe_annual_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, DateTime, Text, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SalesLead(Base):
|
||||
__tablename__ = "sales_leads"
|
||||
__table_args__ = (Index("ix_sales_leads_email", "email"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
company: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
team_size: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
posthog_distinct_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="new")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -62,16 +62,6 @@ class ScriptBuilderSession(Base):
|
||||
nullable=True,
|
||||
comment="Link to FlowPilot session if launched from there",
|
||||
)
|
||||
origin: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="standalone",
|
||||
comment=(
|
||||
"Session origin — 'standalone' (from /script-builder) or "
|
||||
"'pilot_inline' (from FlowPilot Script Builder tab). "
|
||||
"Invariant: pilot_inline rows must have ai_session_id set."
|
||||
),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -78,20 +78,6 @@ class ScriptTemplate(Base):
|
||||
is_gallery_featured: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("false"), index=True)
|
||||
gallery_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
|
||||
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
|
||||
# ── Provenance (Phase 1 — FlowPilot migration) ──
|
||||
# Populated when a template is promoted from a post-resolve draft_templates row.
|
||||
# Powers the Script Library provenance chip:
|
||||
# "generated from CW #X · resolved by Y · used N times"
|
||||
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("ai_sessions.id"), nullable=True,
|
||||
)
|
||||
source_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True,
|
||||
)
|
||||
source_ticket_ref: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True,
|
||||
comment="Human-readable PSA ticket ref for display, e.g. 'CW #48307'",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Session fact model — the "What we know" backing store for a FlowPilot session.
|
||||
|
||||
A fact is an atomic, engineer-readable statement of what has been confirmed
|
||||
during troubleshooting. Facts accumulate across the session and drive the
|
||||
resolution note preview.
|
||||
|
||||
`source_ref` is a polymorphic pointer to a task-lane item inside
|
||||
`ai_sessions.pending_task_lane` JSON — it is NOT a FK. Integrity is enforced
|
||||
at the service layer per the FLOWPILOT-MIGRATION design doc Section 4.2.
|
||||
Phase 2 assigns stable UUIDs to those task-lane items so `source_ref` has
|
||||
something reliable to point to.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Text, DateTime, ForeignKey, String, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class SessionFact(Base):
|
||||
"""A single fact in the What-we-know section of a session's task lane."""
|
||||
__tablename__ = "session_facts"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"source_type IN ('question', 'diagnostic_check', 'user_note', 'ai_synthesis')",
|
||||
name="ck_session_facts_source_type",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# Pointer to a task-lane item UUID inside ai_sessions.pending_task_lane.
|
||||
# NOT a FK. Null for `user_note` and `ai_synthesis` sources.
|
||||
source_ref: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True
|
||||
)
|
||||
source_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Session suggested-fix model — AI-proposed resolution path for a session.
|
||||
|
||||
A session can have multiple suggested fixes over its lifetime as the AI's
|
||||
understanding evolves. Only one is active at a time (superseded_at IS NULL);
|
||||
emitting a new [SUGGEST_FIX] marker supersedes the prior active one.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Text, DateTime, ForeignKey, String, Integer, CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.account import Account
|
||||
from app.models.script_template import ScriptTemplate
|
||||
|
||||
|
||||
class SessionSuggestedFix(Base):
|
||||
"""One AI-proposed fix for a FlowPilot session."""
|
||||
__tablename__ = "session_suggested_fixes"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"confidence_pct BETWEEN 0 AND 100",
|
||||
name="ck_session_suggested_fixes_confidence_pct",
|
||||
),
|
||||
CheckConstraint(
|
||||
"user_decision IS NULL OR user_decision IN ("
|
||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_user_decision",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'applied_pending', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id"),
|
||||
nullable=False,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
confidence_pct: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
script_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_templates.id"),
|
||||
nullable=True,
|
||||
)
|
||||
# Populated only when there's no matching template and the AI has
|
||||
# drafted a session-specific script.
|
||||
ai_drafted_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_drafted_parameters: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Outcome dimension — did the fix work? Orthogonal to user_decision.
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="proposed"
|
||||
)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
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
|
||||
)
|
||||
# Set when a newer suggested fix supersedes this one.
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id])
|
||||
script_template: Mapped["ScriptTemplate | None"] = relationship(
|
||||
"ScriptTemplate", foreign_keys=[script_template_id]
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, DateTime, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StripeEvent(Base):
|
||||
__tablename__ = "stripe_events"
|
||||
__table_args__ = (Index("ix_stripe_events_event_type", "event_type"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(255), primary_key=True) # Stripe event id
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
processed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
payload_excerpt: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
@@ -32,20 +32,8 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in ("active", "trialing", "complimentary")
|
||||
return self.status in ("active", "trialing")
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
|
||||
return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing")
|
||||
|
||||
@property
|
||||
def has_pro_entitlement(self) -> bool:
|
||||
"""True if the account can access Pro features right now."""
|
||||
if self.plan in ("pro", "team"):
|
||||
if self.status in ("active", "complimentary"):
|
||||
return True
|
||||
if self.status == "trialing" and self.current_period_end is not None:
|
||||
from datetime import datetime, timezone
|
||||
return self.current_period_end > datetime.now(timezone.utc)
|
||||
return False
|
||||
return self.plan in ("pro", "team")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -33,7 +33,7 @@ class User(Base):
|
||||
default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -76,8 +76,6 @@ class User(Base):
|
||||
|
||||
# Onboarding
|
||||
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
|
||||
role_at_signup: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
onboarding_step_completed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Branding (solo pros without a team)
|
||||
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -20,7 +20,6 @@ from .psa_connection import (
|
||||
PSATicketSearchResult, PSATicketStatusItem,
|
||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||
PSABoardResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -51,5 +50,4 @@ __all__ = [
|
||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||
"PSABoardResponse",
|
||||
]
|
||||
|
||||
@@ -42,12 +42,3 @@ class AccountInviteResponse(BaseModel):
|
||||
used_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AccountInviteBulkCreate(BaseModel):
|
||||
invites: list[AccountInviteCreate]
|
||||
|
||||
|
||||
class AccountInviteBulkResponse(BaseModel):
|
||||
created: list[AccountInviteResponse]
|
||||
failed: list[dict] # entries shaped {"email": str, "error": str}
|
||||
|
||||
@@ -126,7 +126,6 @@ class AdminAccountDetailResponse(AdminAccountListItem):
|
||||
class AdminAccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
plan: Literal["free", "pro", "team"] = "free"
|
||||
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
||||
|
||||
|
||||
class AdminAccountUpdate(BaseModel):
|
||||
@@ -320,7 +319,7 @@ class AdminUserCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
account_mode: Literal["existing", "personal"]
|
||||
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
|
||||
account_role: Optional[Literal["owner", "admin", "engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
||||
account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
|
||||
send_email: bool = True
|
||||
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from typing import Literal, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CheckoutSessionCreate(BaseModel):
|
||||
plan: Literal["pro", "starter", "team", "enterprise"]
|
||||
seats: int
|
||||
billing_interval: Literal["monthly", "annual"] = "monthly"
|
||||
|
||||
|
||||
class CheckoutSessionResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class SubscriptionState(BaseModel):
|
||||
status: str
|
||||
plan: str
|
||||
current_period_start: Optional[datetime]
|
||||
current_period_end: Optional[datetime]
|
||||
cancel_at_period_end: bool
|
||||
seat_limit: Optional[int]
|
||||
has_pro_entitlement: bool
|
||||
is_paid: bool
|
||||
|
||||
|
||||
class PlanBillingState(BaseModel):
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BillingStateResponse(BaseModel):
|
||||
subscription: SubscriptionState
|
||||
plan_billing: Optional[PlanBillingState]
|
||||
plan_limits: Dict[str, Any]
|
||||
enabled_features: Dict[str, bool]
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Pydantic schemas for device types."""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeviceTypeCreate(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
category: str = Field(
|
||||
min_length=1, max_length=50,
|
||||
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
|
||||
)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class DeviceTypeUpdate(BaseModel):
|
||||
label: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
category: str | None = Field(
|
||||
default=None, min_length=1, max_length=50,
|
||||
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
|
||||
)
|
||||
sort_order: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class DeviceTypeResponse(BaseModel):
|
||||
id: UUID
|
||||
slug: str
|
||||
label: str
|
||||
category: str
|
||||
is_system: bool
|
||||
account_id: UUID
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Pydantic schemas for FlowPilot Phase 6 draft templates.
|
||||
|
||||
A draft is the engineer's "Run now, templatize after resolve" path output:
|
||||
the script ran for the ticket, and the AI proposed a parameterization.
|
||||
Post-resolve, the engineer accepts (promotes to a real template) or rejects.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DraftStatus = Literal["pending", "accepted", "rejected"]
|
||||
|
||||
|
||||
class DraftTemplateResponse(BaseModel):
|
||||
id: UUID
|
||||
account_id: UUID
|
||||
source_session_id: UUID
|
||||
source_user_id: UUID
|
||||
script_body: str
|
||||
proposed_parameters: dict[str, Any]
|
||||
proposed_name: str | None
|
||||
proposed_category_id: UUID | None
|
||||
status: DraftStatus
|
||||
resolved_at: datetime | None
|
||||
promoted_template_id: UUID | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DraftTemplateListResponse(BaseModel):
|
||||
drafts: list[DraftTemplateResponse]
|
||||
|
||||
|
||||
class DraftTemplateAcceptRequest(BaseModel):
|
||||
"""Engineer's confirmation that this draft should become a real template.
|
||||
|
||||
Engineer may override the AI's proposed name / category and edit the
|
||||
parameter schema before promotion. Body and parameters_schema are
|
||||
persisted to the new `script_templates` row.
|
||||
"""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
category_id: UUID
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
# Final parameter schema in the Script Generator's standard shape.
|
||||
# See ScriptTemplate.parameters_schema for the contract.
|
||||
parameters_schema: dict[str, Any]
|
||||
# Optional last-minute edits to the script body. Defaults to the draft's
|
||||
# `script_body` (which TemplateExtractionService produced as the templated
|
||||
# form with `{{ key }}` placeholders).
|
||||
edited_body: str | None = Field(None, min_length=1, max_length=50_000)
|
||||
|
||||
|
||||
class DraftTemplateAcceptResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
promoted_template_id: UUID
|
||||
template_slug: str
|
||||
|
||||
|
||||
class DraftTemplateRejectResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
status: Literal["rejected"]
|
||||
@@ -124,26 +124,3 @@ 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."
|
||||
)
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Pydantic schemas for network diagrams."""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Position(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class DeviceProperties(BaseModel):
|
||||
hostname: str | None = None
|
||||
ip: str | None = None
|
||||
subnet: str | None = None
|
||||
vendor: str | None = None
|
||||
model: str | None = None
|
||||
role: str | None = None
|
||||
vlan: str | None = None
|
||||
notes: str | None = None
|
||||
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
|
||||
|
||||
|
||||
class NodeStyle(BaseModel):
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
|
||||
|
||||
class DiagramNode(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
position: Position
|
||||
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
||||
nodeType: str | None = None
|
||||
style: NodeStyle | None = None
|
||||
parentId: str | None = None
|
||||
|
||||
|
||||
class DiagramEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str | None = None
|
||||
connectionType: str = "ethernet"
|
||||
speed: str | None = None
|
||||
notes: str | None = None
|
||||
routing: str | None = None
|
||||
|
||||
|
||||
class NetworkDiagramCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
description: str | None = None
|
||||
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NetworkDiagramUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
description: str | None = None
|
||||
nodes: list[DiagramNode] | None = None
|
||||
edges: list[DiagramEdge] | None = None
|
||||
|
||||
|
||||
class NetworkDiagramResponse(BaseModel):
|
||||
id: UUID
|
||||
account_id: UUID
|
||||
name: str
|
||||
client_name: str | None = None
|
||||
asset_name: str | None = None
|
||||
description: str | None = None
|
||||
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||
thumbnail_url: str | None = None
|
||||
is_archived: bool = False
|
||||
created_by: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NetworkDiagramListItem(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
client_name: str | None = None
|
||||
description: str | None = None
|
||||
node_count: int = 0
|
||||
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||
thumbnail_url: str | None = None
|
||||
created_by: UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ExistingBounds(BaseModel):
|
||||
minX: float
|
||||
maxX: float
|
||||
minY: float
|
||||
maxY: float
|
||||
|
||||
|
||||
class AIGenerateRequest(BaseModel):
|
||||
description: str = Field(min_length=1, max_length=5000)
|
||||
client_name: str | None = None
|
||||
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
|
||||
existingBounds: ExistingBounds | None = None
|
||||
|
||||
|
||||
class AIGenerateResponse(BaseModel):
|
||||
nodes: list[DiagramNode]
|
||||
edges: list[DiagramEdge]
|
||||
suggestedName: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class DiagramImportRequest(BaseModel):
|
||||
schemaVersion: int = Field(ge=1, le=1)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
client_name: str | None = None
|
||||
description: str | None = None
|
||||
nodes: list[DiagramNode] = Field(default_factory=list)
|
||||
edges: list[DiagramEdge] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DiagramImportResponse(BaseModel):
|
||||
diagram: NetworkDiagramResponse
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DiagramExportResponse(BaseModel):
|
||||
schemaVersion: int = 1
|
||||
name: str
|
||||
client_name: str | None = None
|
||||
description: str | None = None
|
||||
nodes: list[DiagramNode]
|
||||
edges: list[DiagramEdge]
|
||||
exportedAt: str
|
||||
@@ -1,13 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OAuthCallbackPayload(BaseModel):
|
||||
code: str
|
||||
state: str | None = None
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
@@ -53,13 +53,9 @@ 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
|
||||
|
||||
|
||||
@@ -115,13 +111,13 @@ class PsaPostLogResponse(BaseModel):
|
||||
|
||||
|
||||
class PsaMemberMappingResponse(BaseModel):
|
||||
id: str | None = None # None for users without a mapping
|
||||
id: str
|
||||
user_id: str
|
||||
user_email: str
|
||||
user_name: str
|
||||
external_member_id: str | None = None
|
||||
external_member_name: str | None = None
|
||||
matched_by: str | None = None
|
||||
external_member_id: str
|
||||
external_member_name: str
|
||||
matched_by: str
|
||||
|
||||
|
||||
class PsaMemberMappingSaveRequest(BaseModel):
|
||||
@@ -140,8 +136,3 @@ class PsaMemberResponse(BaseModel):
|
||||
class AutoMatchResult(BaseModel):
|
||||
matched: list[PsaMemberMappingResponse]
|
||||
unmatched_users: int
|
||||
|
||||
|
||||
class PSABoardResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""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
|
||||
@@ -1,27 +1,18 @@
|
||||
"""Pydantic schemas for the AI Script Builder."""
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScriptBuilderCreateRequest(BaseModel):
|
||||
"""Request to start (or get-or-create, for inline origin) a builder session.
|
||||
|
||||
When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must
|
||||
reference a pilot session owned by the current user. The endpoint's
|
||||
get-or-create semantics kick in: if a pilot_inline session already
|
||||
exists for (user_id, ai_session_id), that row is returned instead of
|
||||
creating a duplicate.
|
||||
"""
|
||||
"""Request to start a new builder session."""
|
||||
language: str = Field(
|
||||
default="powershell",
|
||||
pattern=r"^(powershell|bash|python)$",
|
||||
description="Script language",
|
||||
)
|
||||
origin: Literal["standalone", "pilot_inline"] = "standalone"
|
||||
ai_session_id: UUID | None = None
|
||||
|
||||
|
||||
class ScriptBuilderMessageRequest(BaseModel):
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Pydantic schemas for the FlowPilot "What we know" session facts.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 4.2 for the data model rationale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# AI-emittable source types are a subset (`user_note` is engineer-only).
|
||||
AIEmittableSourceType = Literal["question", "diagnostic_check", "ai_synthesis"]
|
||||
SourceType = Literal["question", "diagnostic_check", "user_note", "ai_synthesis"]
|
||||
|
||||
|
||||
class SessionFactResponse(BaseModel):
|
||||
"""A single fact card in the What-we-know panel."""
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
text: str
|
||||
source_type: SourceType
|
||||
source_ref: UUID | None
|
||||
source_summary: str | None
|
||||
created_by: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# `editable` is computed server-side so the client doesn't have to
|
||||
# re-encode the editability rule. It mirrors the PATCH endpoint's
|
||||
# 403 policy: only user_note and ai_synthesis facts are editable.
|
||||
editable: bool
|
||||
|
||||
model_config = {"from_attributes": False}
|
||||
|
||||
|
||||
class SessionFactListResponse(BaseModel):
|
||||
facts: list[SessionFactResponse]
|
||||
|
||||
|
||||
class SessionFactCreateRequest(BaseModel):
|
||||
"""Engineer-created manual fact (the "+ Add a note" affordance).
|
||||
|
||||
The endpoint hard-codes source_type="user_note" — manual creation cannot
|
||||
spoof a question/check origin. Source-type-bound creation goes through
|
||||
`/promote` instead.
|
||||
"""
|
||||
text: str = Field(..., min_length=1, max_length=2000)
|
||||
summary: str | None = Field(None, max_length=200)
|
||||
|
||||
|
||||
class SessionFactUpdateRequest(BaseModel):
|
||||
"""Edit an existing fact's text or summary.
|
||||
|
||||
The endpoint returns 403 when the fact's source_type is `question` or
|
||||
`diagnostic_check` — those facts must be edited at the source item.
|
||||
"""
|
||||
text: str | None = Field(None, min_length=1, max_length=2000)
|
||||
summary: str | None = Field(None, max_length=200)
|
||||
|
||||
|
||||
class SessionFactPromoteRequest(BaseModel):
|
||||
"""Promote a question answer / check result into a fact.
|
||||
|
||||
Two modes:
|
||||
- **Direct**: caller provides `proposed_text` (and optionally `proposed_summary`).
|
||||
The fact is persisted as-is. Used by the AI [PROMOTE] marker path and by the
|
||||
engineer's "edit then save" affordance.
|
||||
- **Synthesize**: caller provides `raw_input` (the engineer's typed answer or
|
||||
the check output) and the server drafts `text`/`summary` via the
|
||||
FactSynthesisService. The draft is persisted immediately for now —
|
||||
the supervisor-staging review is a future enhancement (out of scope per
|
||||
Section 12).
|
||||
|
||||
Exactly one of `proposed_text` or `raw_input` must be set.
|
||||
"""
|
||||
source_type: AIEmittableSourceType
|
||||
source_ref: UUID | None = None
|
||||
proposed_text: str | None = Field(None, min_length=1, max_length=2000)
|
||||
proposed_summary: str | None = Field(None, max_length=200)
|
||||
raw_input: str | None = Field(None, min_length=1, max_length=10_000)
|
||||
@@ -10,18 +10,12 @@ 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]
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Pydantic schemas for session suggested fixes (Phase 3).
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"]
|
||||
|
||||
# "dismissed" here is the outcome dimension — orthogonal to UserDecision's
|
||||
# "dismissed" (script-path choice), though the migration backfill aligns
|
||||
# them for pre-existing rows.
|
||||
FixStatus = Literal[
|
||||
"proposed",
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
|
||||
class SessionSuggestedFixResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
title: str
|
||||
description: str
|
||||
confidence_pct: int
|
||||
script_template_id: UUID | None
|
||||
ai_drafted_script: str | None
|
||||
ai_drafted_parameters: dict[str, Any] | None
|
||||
user_decision: UserDecision | None
|
||||
superseded_at: datetime | None
|
||||
created_at: datetime
|
||||
status: FixStatus
|
||||
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
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SessionSuggestedFixDecisionRequest(BaseModel):
|
||||
"""Engineer's path choice on a suggested fix.
|
||||
|
||||
Server-side side effects per Section 5.2:
|
||||
- one_off: record decision, return the rendered (AI-drafted or
|
||||
engineer-edited) script. No persistent library artifact created.
|
||||
- draft_template: same as one_off, plus TemplateExtractionService
|
||||
proposes a parameterization and a draft_templates row is created.
|
||||
- build_template: return a redirect payload pointing at the Script
|
||||
Builder page, pre-loaded with the drafted script body.
|
||||
- dismissed: mark the fix superseded.
|
||||
|
||||
For one_off / draft_template, the engineer may have edited the drafted
|
||||
script or its parameters in the dialog. The final versions are sent
|
||||
back here so we persist what will actually run.
|
||||
"""
|
||||
decision: UserDecision
|
||||
# Present for one_off / draft_template — the engineer's final version of
|
||||
# the drafted script after any inline edits. Omit to use the fix's
|
||||
# `ai_drafted_script` verbatim.
|
||||
edited_script: str | None = Field(None, min_length=1, max_length=50_000)
|
||||
# Parameter values used when rendering (informational, stored on the
|
||||
# draft_template row so a reviewer can see what the first run used).
|
||||
parameters_used: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SessionSuggestedFixDecisionResponse(BaseModel):
|
||||
"""Returned after recording a decision."""
|
||||
id: UUID
|
||||
user_decision: UserDecision
|
||||
# Populated for one_off / draft_template — the script to display/run.
|
||||
rendered_script: str | None = None
|
||||
# Populated for draft_template — the ID of the draft_templates row so
|
||||
# the post-resolve TemplatizePrompt can fetch it in Phase 6.
|
||||
draft_template_id: UUID | None = None
|
||||
# Populated for build_template — where to send the engineer next.
|
||||
redirect_path: str | None = Field(
|
||||
None,
|
||||
description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)",
|
||||
)
|
||||
|
||||
|
||||
# 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",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
|
||||
class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||||
"""Engineer-reported outcome of applying a suggested fix.
|
||||
|
||||
Writes to session_suggested_fixes.status and companion columns. This is
|
||||
orthogonal to `user_decision` (which records which script-path the
|
||||
engineer took); outcome captures whether the fix actually worked.
|
||||
|
||||
Allowed transitions:
|
||||
- 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 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)
|
||||
|
||||
|
||||
class SessionSuggestedFixScriptRequest(BaseModel):
|
||||
"""Engineer-submitted drafted script for a suggested fix.
|
||||
|
||||
Called when the inline Script Builder tab's Submit action fires. The
|
||||
fix must be non-terminal (still proposed/applied_partial). Setting
|
||||
the script does NOT stamp applied_at — a draft is not an application.
|
||||
"""
|
||||
ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
|
||||
ai_drafted_parameters: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ── Resolution note preview ────────────────────────────────────────────────
|
||||
|
||||
class ResolutionNotePreviewResponse(BaseModel):
|
||||
markdown: str
|
||||
target_ticket_ref: str | None
|
||||
state_version: int
|
||||
from_cache: bool
|
||||
|
||||
|
||||
# ── Phase 4: Resolve + Escalate post ───────────────────────────────────────
|
||||
|
||||
class ResolutionNotePostRequest(BaseModel):
|
||||
"""Engineer-edited resolution markdown. Server posts to PSA + marks resolved."""
|
||||
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||
# Optional override for resolution summary shown on the session listing;
|
||||
# defaults to the first line of the markdown if omitted.
|
||||
resolution_summary: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class EscalationPackagePostRequest(BaseModel):
|
||||
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||
# Free-text reason shown in session listings and escalation queue.
|
||||
escalation_reason: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class ResolutionPostResponse(BaseModel):
|
||||
"""Response shape for both Resolve/Escalate POST endpoints."""
|
||||
# "resolved" / "escalated" / "resolved_local" / "escalated_local"
|
||||
# The _local variants indicate the session has no linked PSA ticket —
|
||||
# markdown is stored, session state is updated, nothing was posted externally.
|
||||
outcome: str
|
||||
session_status: str
|
||||
external_id: str | None = None
|
||||
posted_at: datetime | None = None
|
||||
# Populated when a status transition was attempted and verified. None
|
||||
# when no target status is configured in account_settings.preferences.
|
||||
verified_status_id: int | None = None
|
||||
verified_status_name: str | None = None
|
||||
status_transition_skipped_reason: str | None = None
|
||||
@@ -68,6 +68,4 @@ class RoleUpdate(BaseModel):
|
||||
|
||||
|
||||
class AccountRoleUpdate(BaseModel):
|
||||
# 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)$")
|
||||
account_role: str = Field(..., pattern="^(engineer|viewer)$")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user