110 Commits

Author SHA1 Message Date
ca45bc9bb3 perf(ci): pytest-xdist with per-worker DBs — 22m → ~4m
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / backend (pull_request) Successful in 9m37s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Failing after 20m54s
Backend suite is the slow gate (1076 passed locally in 22m27s on
fix/ci-workflow-config). Adding pytest-xdist with per-worker DB
isolation drops it to ~4m20s on the 8-core homelab runner. Verified
locally: `pytest -n auto --no-cov` finished in 4m28s real time
(15m19s user — confirms ~5× parallelism).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behavior change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 14:50:41 -04:00
e110fedfe4 chore: snapshot CLAUDE.md before ai-handoff migration 2026-04-24 14:21:21 -04:00
dab740ddf7 fix(tests): isolate test DB from dev DB and plug admin-db override gap
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Root cause of the 06:32 AM outage: running 'pytest tests/' inside the
resolutionflow_backend container silently dropped the public schema on
the DEV database. Two layered bugs made this possible; both are fixed.

Bug 1 — env-var lookup in conftest.TEST_DATABASE_URL put DATABASE_URL
(which normally points at the dev/prod DB) ahead of DATABASE_TEST_URL.
When DATABASE_URL is set, pytest used the dev DB as the 'test' DB and
the test_db fixture's DROP SCHEMA public CASCADE wiped it. Fixed:
  - Honor only DATABASE_TEST_URL (or the localhost fallback).
  - Assert at module load that the DB name contains 'test' — refuses
    to run otherwise. Makes future misconfiguration impossible.

Bug 2 — conftest overrode app.dependency_overrides[get_db] but not
get_admin_db. Endpoints using get_admin_db (register, admin routes)
bypassed the test session and hit the real admin DB. Before Bug 1 was
fixed this was hidden because both engines pointed at the same dev DB.
With isolation in place, register started failing 'Email already
registered' because of stale users in the dev DB. Fixed:
  - Also override get_admin_db to yield the same test session. RLS is
    not enabled in the create_all-managed test schema, so sharing is
    safe.

Also adds DATABASE_TEST_URL=resolutionflow_test to docker-compose.dev.yml
so pytest in the container works out of the box.

Verified: 49/50 Phase 8 + 9 tests pass against resolutionflow_test; the
1 failure is the pre-existing Phase 8 Issue #4
(test_record_decision_persists_and_bumps_state_version).

Refs gitea #145 (will update that issue with this as the primary fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:14:08 -04:00
24972e8444 fix(pilot): Phase 9 review — partial-outcome notes + per-fix script-builder remount
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
Addresses docs/FlowAssist_Migration/Issues/phase-9-review-issues.md.

Issue #1 (High): "Applied partially" from the escalation intercept silently
dropped because the backend requires notes on applied_partial and the dialog
sent none. The catch was silent and the UI advanced into the conclude flow
as if the outcome were recorded.
- EscalateInterceptDialog now has a two-step flow: clicking the partial
  choice reveals a notes textarea (autofocused, required non-empty) plus
  Back / "Record partial & escalate" buttons.
- onChoose signature extended to (choice, notes?).
- handleInterceptChoice passes notes to patchOutcome; on failure it
  surfaces a toast and does NOT advance to the conclude modal, so the
  intercept stays open for retry.

Issue #2 (Medium/High): ScriptBuilderTab kept local state across active-fix
changes within the same pilot session, so a stale draft could PATCH against
a newer fix.id. Added key={activeFix.id} on the mount — forces a clean
remount per fix; backend get-or-create (keyed on user+ai_session_id) still
returns the same session row, which is the intended resume-on-refresh
semantic; but messages/editorBuffer/latestScript local state resets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:08:00 -04:00
d386d11af2 docs(pilot): correct Phase 9 migration description
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
Handoff + migration spec incorrectly claimed Phase 9 added a new
parent_pilot_session_id FK. The implementation reuses the existing
ai_session_id column; the migration only adds the origin discriminator
+ partial unique index. Also: ScriptBuilderTab wraps ScriptBuilderChat
and ScriptBodyEditor (Monaco), not "ScriptBuilderChat in ephemeral
mode" — there is no ephemeral mode on the presentational component.

Applies applied_at call-site specifics: handleScriptDecision stamps
on one_off/draft_template, TemplateMatchPanel stamps on onMarkRun,
Script Builder tab Submit does not stamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:17:08 -04:00
65a831bf9a docs(pilot): Phase 9 handoff + migration spec update
Marks open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed
Script Builder) as resolved. Records the applied_at semantics
correction as shipped. Final Phase 9 row added to the 'What shipped'
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:14:41 -04:00
faf1d8dd12 fix(pilot): applied_at stamps on run-declaring actions, not Apply click
Per Phase 9 §5. Before: banner Apply click stamped applied_at
regardless of whether the engineer had committed to running anything,
starting the Verifying timer prematurely. After:

- handleApplyFix no longer calls applyFix(). It just routes to the
  right surface (TemplateMatchPanel / InlineNoTemplateDialog / Script
  Builder tab).
- handleScriptDecision stamps applied_at for one_off + draft_template
  (both labels are 'Run now, …' — the click is the declaration).
  build_template does not stamp.
- TemplateMatchPanel's new 'I ran this' button calls applyFix via a
  new onMarkRun prop.
- Script Builder tab Submit does not stamp (a draft is not a run).

No backend change — the /apply endpoint is unchanged. Only call sites
move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 04:11:56 -04:00
0386fa1fd5 feat(pilot): mount ChatTabStrip + ScriptBuilderTab + InlineNoTemplateDialog
Wires the three new components into AssistantChatPage:
- ChatTabStrip renders when the active fix needs a script drafted.
- ScriptBuilderTab sits alongside chat via display:none toggling so
  chat scroll position + builder state both persist.
- InlineNoTemplateDialog replaces the task-lane bottomSlot render for
  the drafted-script evaluation case; three cards finally fit.
- Banner Apply routing updated: no-draft/no-template → Script Builder
  tab; drafted → InlineNoTemplateDialog; template → unchanged path.

applyFix() call site moves land in the next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 04:02:20 -04:00
82db1c78e4 feat(pilot): EscalateInterceptDialog — fourth 'partial' choice
Closes the gap Phase 8 final review flagged. When a fix is in
applied_partial state and the engineer escalates, the intercept no
longer forces them to approximate with didn't-work/worked/never-applied.

AssistantChatPage's handleInterceptChoice (Task 13) already dispatches
to patchOutcome for any FixOutcome value, so no handler change is
needed — the type already supports applied_partial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:04:05 -04:00
f930787200 feat(pilot): TemplateMatchPanel — explicit 'I ran this' action
Generate and Copy alone don't declare a run — the engineer can walk
away after copying. Phase 9 §5 defines an explicit run-declaration
affordance so applied_at only stamps on the engineer's positive
commitment. Wiring from AssistantChatPage lands in Task 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:02:17 -04:00
5bcb7aa7c3 feat(pilot): InlineNoTemplateDialog — chat-region placement wrapper
Slide-up wrapper around the existing NoTemplateDialog for rendering
in the chat region above the composer (parallel to ProposalBanner).
The chat region's width lets grid-cols-3 finally work as intended.

No change to NoTemplateDialog itself; decision callbacks and card
copy stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:56:35 -04:00
04fbfe3b8f feat(pilot): ScriptBuilderTab controller
Owns the inline Script Builder session lifecycle:
- Get-or-create (origin='pilot_inline', ai_session_id) on mount.
- Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in
  'Write it myself' mode. Mode toggles via display:none so buffer and
  messages persist across switches.
- Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted
  to parent, which refreshes the fix and hides the tab strip.
- Relays in-progress state to the parent via onProgressChange for the
  ChatTabStrip's indicator dot.

ScriptBuilderChat is untouched (stays presentational). Persistence
semantics live on the controller, not the display component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:55:12 -04:00
f92cbefed9 feat(pilot): ChatTabStrip component — [Chat] [Script Builder ●]
Two-tab strip for the chat region. Parent controls mounting (strip only
appears when the fix needs a script drafted). Indicator dot signals
in-progress draft state. Tab switching via onChange callback; parent
handles display:none toggling so tab contents preserve state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:45:16 -04:00
c9306e40c9 feat(pilot): frontend API client — patchScript + inline createSession
sessionSuggestedFixesApi.patchScript(sessionId, fixId, script, params?)
hits the new PATCH /script endpoint.

scriptBuilder.createSession accepts an optional options bag with
origin + aiSessionId, defaulting to standalone when omitted so legacy
callers stay behavior-preserving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:38:07 -04:00
1c855563ee feat(pilot): PATCH /suggested-fixes/:id/script endpoint
Called by the inline Script Builder tab on Submit. Writes
ai_drafted_script + ai_drafted_parameters to the fix without stamping
applied_at (a draft is not an application — that's §5 of the Phase 9
spec). Bumps state_version so Resolve/Escalate preview bundles
regenerate.

409 on terminal fix status. 404 on wrong session. 422 on empty script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:34:06 -04:00
d4fae87236 feat(pilot): inline Script Builder session — idempotent create + auth + filtered list
POST /script-builder/sessions now supports origin='pilot_inline':
- Requires ai_session_id; validates it against current user ownership.
- Get-or-create: returns existing row for (user, ai_session_id) pair.
- Partial unique index on the DB backs the invariant; races resolve to
  the single winner row.

list_sessions + count_user_sessions default-scope to origin='standalone'
so inline scratch sessions don't pollute the /script-builder dashboard
or count against the 5-session cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:24:57 -04:00
f2fce27f0d feat(pilot): pydantic schemas for inline origin + script PATCH
- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline')
  and optional ai_session_id. Handler-side validation (next task) enforces
  pilot_inline ⇒ ai_session_id required + owned by caller.
- SessionSuggestedFixScriptRequest added for the new PATCH /script
  endpoint (Phase 9 Task 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:53:28 -04:00
93c974466a feat(pilot): script_builder_sessions.origin on SQLAlchemy model
Mirrors the DB column added in the prior migration. App-level default
is 'standalone' so existing callers of ScriptBuilderSession(...) work
without code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:48:22 -04:00
8012668975 feat(pilot): add origin + inline idempotency to script_builder_sessions
Phase 9 prep. Adds:
- origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline')
- invariant: pilot_inline rows must have ai_session_id
- partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline'
  — backs get-or-create idempotency for the inline Script Builder tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:22:53 -04:00
563bb1aa6f docs(pilot): Phase 9 implementation plan
14-task plan covering:
- DB migration for origin + partial unique index on script_builder_sessions
- Pydantic schemas for inline origin + PATCH /script
- POST /script-builder/sessions idempotent for pilot_inline + auth
- list_sessions + count_user_sessions filtered to standalone
- PATCH /suggested-fixes/:id/script (bumps state_version, no applied_at)
- Frontend API client additions
- ChatTabStrip, ScriptBuilderTab (controller), InlineNoTemplateDialog
- TemplateMatchPanel 'I ran this' action
- EscalateInterceptDialog fourth 'partial' choice
- AssistantChatPage integration + applyFix call-site relocation
- Docs + handoff updates

Paired with the spec at phase-9-script-builder-tab.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:03:57 -04:00
1d2d548fc8 docs(pilot): Phase 9 spec — final consistency polish
- Frontend scriptBuilder API client inventory now matches the backend
  schema: createSession accepts BOTH origin and ai_session_id (both
  required together for inline callers, both omitted for standalone).
- 'If template -> unchanged' sharpened: render location is unchanged,
  but run stamping moves into the panel's new 'I ran this' action per
  the §5 apply lifecycle correction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:54:04 -04:00
3ee0101c6d docs(pilot): Phase 9 spec — ownership + schema corrections
- scriptBuilderMode ownership: pinned to ScriptBuilderTab, removed from
  AssistantChatPage's state list. Parent never drives the AI/editor
  toggle; controller owns it and resets naturally on session switch via
  unmount/remount. scriptBuilderHasProgress stays on the page (needed
  for the tab strip indicator dot) and is driven by the controller via
  an onProgressChange callback.
- ScriptBuilderCreateRequest schema: explicitly calls for TWO new
  optional fields (origin + ai_session_id), not just origin. Handler
  enforces: when origin='pilot_inline', ai_session_id is required and
  must pass the current-user ownership check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:08 -04:00
861d082ff7 docs(pilot): Phase 9 spec — consistency pass on Apply stamp call sites
Three consistency fixes:
- File inventory (backend + frontend) now names all three apply-stamp
  call sites: handleScriptDecision('one_off' | 'draft_template') plus
  TemplateMatchPanel's 'I ran this' handler. Previously listed only
  'one_off' in two places, contradicting the §5 lifecycle table.
- NoTemplateDialog relocation section no longer claims the decision
  handler is 'unchanged' — it is unchanged EXCEPT for the moved
  apply stamp, which is the point of §5.
- Open deferrals entry on ScriptBuilderChat 'ephemeral mode' removed;
  replaced with the actual new surface (ScriptBuilderTab controller),
  which reuses the existing script-builder prompt unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:41:17 -04:00
75b59123e6 docs(pilot): Phase 9 spec — fix Apply semantics + session idempotency
Four review findings addressed:

- High: draft_template 'Run now, templatize after' DOES run the
  script; applied_at table now stamps for both one_off and
  draft_template. Only build_template (no run) skips the stamp.
- Medium: TemplateMatchPanel needs an explicit '✓ I ran this' button.
  Generate/Copy don't commit to running. The new button is the stamp
  moment for template-match fixes.
- Medium: get-or-create for inline script_builder_sessions —
  POST /script-builder/sessions is now idempotent for
  origin='pilot_inline' (returns the existing row for a
  (user, ai_session_id) pair). Backed by a partial unique index:
    UNIQUE (user_id, ai_session_id) WHERE origin = 'pilot_inline'
  so remount doesn't create duplicates and draft continuity is
  preserved.
- Medium: authorization — the create endpoint validates that any
  provided ai_session_id is owned by the current user (same guard
  other pilot endpoints use). Prevents cross-user attachment of
  scratch sessions to arbitrary pilot sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:34:53 -04:00
fcd224429c docs(pilot): revise Phase 9 spec per review findings
Four findings addressed:

- High: drop proposed parent_pilot_session_id column; reuse the
  existing ai_session_id FK on script_builder_sessions. Add an
  origin + ai_session_id coherence invariant.
- High: don't add a 'mode' prop to ScriptBuilderChat (it's
  presentational). Introduce a ScriptBuilderTab controller that owns
  session lifecycle + submit, renders ScriptBuilderChat unchanged.
- Medium: filter list_sessions / count_user_sessions to origin='standalone'
  so pilot_inline scratch sessions don't pollute the /script-builder
  dashboard or count against the 5-session cap.
- Medium: applied_at is stamped only when the engineer commits to a
  run-action (one_off, TemplateMatchPanel Run), not on banner Apply
  click. Corrects a Phase 8 over-eager stamp that would otherwise
  multiply across three surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:28:53 -04:00
196c003876 docs(pilot): Phase 9 spec — tabbed Script Builder + NoTemplateDialog relocation
Design doc for the FlowPilot migration's remaining open items:
- NoTemplateDialog narrow-lane bug (resolved by moving the dialog to
  the chat region alongside ProposalBanner — three cards fit naturally
  at that width; grid-cols fix no longer needed)
- Tabbed Script Builder inside the chat (new [Chat] [Script Builder ●]
  tab strip; AI chat default with 'Write it myself' Monaco escape hatch)

Plus a Phase 8 cleanup:
- EscalateInterceptDialog fourth 'I applied some of it — partial' choice

All six architecture decisions settled via brainstorming before writing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:03:57 -04:00
f2b9476edb docs(pilot): log Issues #1-4 findings for Phase 8 review
Tracks the three code-review issues that were fixed on this branch
(#1 outcome-aware previews, #2 persist Apply, #3 persist proposal
rejection) plus a newly-documented pre-existing test failure (#4 —
decision-endpoint test written in Phase 3 never updated when Phase 5
added the drafted-script validation guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:18:13 -04:00
70c5da0c75 fix(pilot): persist AI-proposal rejection + clear on outcome write
Issue #3 from phase-8-review-issues.md. 'Not yet' on the AI-confirming
banner was a local-state hide; the proposal re-surfaced on the next
refreshSessionDerived call.

Two-part fix:
- PATCH /outcome now clears ai_outcome_proposal on any terminal action
  (engineer has taken a decision; stale AI proposal is moot).
- New DELETE /ai-sessions/:sid/suggested-fixes/:fid/ai-outcome-proposal
  endpoint for explicit 'Not yet' rejection. Does not touch status
  or state_version — pure UI state.

Frontend handleRejectAIProposal now calls the DELETE and setActiveFix
with the server response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:15:48 -04:00
de2bef3175 fix(pilot): persist Apply — stamp applied_at on click
Issue #2 from phase-8-review-issues.md. Apply was client-side-only via
a bannerApplied flag. Refresh / chat reselect / multi-tab would drop
Verifying state back to Proposed.

- New POST /ai-sessions/{sid}/suggested-fixes/{fid}/apply stamps
  applied_at without changing status (still 'proposed'). Idempotent
  if already stamped; 409 if fix is past proposed (a terminal outcome
  was already recorded).
- Bumps state_version so resolve/escalate preview bundles reflect that
  the fix has entered verifying.
- Frontend handleApplyFix calls the endpoint and uses the returned
  applied_at directly. bannerApplied client flag is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:10:52 -04:00
362c7b1d79 fix(pilot): outcome-aware Resolve/Escalate previews
Issue #1 from phase-8-review-issues.md. Cache invalidation alone isn't
enough — previews were also omitting outcome fields from the LLM bundle,
so a fresh regenerate still couldn't distinguish proposed / failed /
partial / success.

- PATCH /outcome now bumps ai_sessions.state_version (matches
  record_decision's existing pattern).
- Resolution-note + escalation-package bundles now include status,
  applied_at, verified_at, partial_notes, failure_reason on the active fix.
- Generator prompts prescribe outcome-aware phrasing (closure language
  for success; what-we've-tried + next-steps for failed/partial).
- New end-to-end test asserts the regenerated preview reflects the
  recorded outcome, not just that the cache key changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:04:56 -04:00
ec104dc8de docs(pilot): sync Phase 8 handoff with actual implementation
Correct the stale ai_sessions.fix_outcome reference (no such column) —
the real schema adds six columns to session_suggested_fixes. Update
last_commit to reflect the docs-correction tip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:48:54 -04:00
a47ce07326 docs(pilot): fix Phase 8 column + commit-SHA references
Correct the FLOWPILOT-MIGRATION.md stale references to a non-existent
ai_sessions.fix_outcome column — the actual implementation added six
columns to session_suggested_fixes. Also fix a stale first-commit SHA
(6721b84 → cdd8bb0, the former was amended away).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:42:51 -04:00
2a54127a54 docs(pilot): Phase 8 fix outcome banner — handoff + migration spec
Marks open item #2 (task-lane crowding / Suggested Fix discoverability)
as resolved by Phase 8. Open items #1 (NoTemplateDialog narrow-lane)
and #3 (Tabbed Script Builder inside chat) remain deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:52:07 -04:00
8582d24236 chore(pilot): remove deprecated SuggestedFix task-lane card
Superseded by ProposalBanner (Phase 8). The import was already removed
from AssistantChatPage in the previous commit; this deletes the orphaned
file itself and strips the now-unused suggestedFixSlot prop from
TaskLane's interface and both call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:48:42 -04:00
bdb238a274 feat(pilot): mount ProposalBanner + wire implicit signals
Replaces the task-lane SuggestedFix card with the ProposalBanner docked
above the chat composer. Wires:
- Resolve-while-verifying auto-marks applied_success (one-click resolve).
- Escalate-while-verifying opens EscalateInterceptDialog to capture the
  real outcome (default: didn't work) before handoff.
- 3+ post-apply engineer messages trigger the passive Nudge banner.
- AI [FIX_OUTCOME] proposals surface in the AIConfirming state; one-click
  confirm applies the outcome.

Banner state resets on session switch via resetSessionDerivedState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:42:01 -04:00
075b0fc1d8 feat(pilot): EscalateInterceptDialog popover
Anchored above the Escalate button, captures fix outcome before the
engineer hands off the ticket. Defaults to 'didn't work' on Enter
(the common case). Alternatives: 'worked, escalating for another
reason' (preserves success) and 'never actually applied' (dismiss).

Task 11 will wire this to AssistantChatPage's Escalate handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:48:33 -04:00
217747f46e feat(pilot): banner AI-confirming, Nudge, Collapsed states
Completes ProposalBanner's state machine. AIConfirming (accent-blue)
surfaces the AI's [FIX_OUTCOME] proposal with one-click accept; Nudge
is the compact passive-prompt variant for post-apply chats; Collapsed
is the 28px expand-hint strip.

Adds onSilenceNudge prop so the parent can silence the nudge without
collapsing it (Task 11 wires this). Removes the last three stale
eslint-disable-next-line comments — all sub-components now use props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:39:08 -04:00
7fa1d6a32f feat(pilot): banner Verifying + Partial states
Verifying: amber pulse animation, confidence pill becomes 'Applied Xm ago',
three actions (overflow for Mark partial, Didn't work, It worked). window.prompt
used for the partial notes + failure reason inputs — good-enough v1 pending
an inline composer.

Partial: cyan-toned to signal 'parked, outcome unknown', shows saved notes
inline, Finish it / Didn't work / It worked actions.

Adds pulse-amber to @theme animations alongside slide-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:32:02 -04:00
ac67e48500 feat(pilot): ProposalBanner scaffold + Proposed state
New component that will replace the task-lane SuggestedFix card. Docks
above the chat composer with a 320ms slide-up animation. This commit
implements only the Proposed state (Tasks 8 & 9 fill Verifying, Partial,
AI-confirming, Nudge, Collapsed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:25:41 -04:00
cdd29b460e feat(pilot): frontend fix-outcome types + patchOutcome API
Extends SessionSuggestedFix with outcome fields (status, applied_at,
verified_at, partial_notes, failure_reason, ai_outcome_proposal) and
adds a patchOutcome method hitting the new backend endpoint.

FixStatus (5 values) + FixOutcome (4 writable values) mirror the
backend Pydantic types and the DB check constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:20:16 -04:00
2cde6673b0 feat(pilot): [FIX_OUTCOME] system prompt instructions
Tells the AI when + how to emit the [FIX_OUTCOME] marker that Task 4's
parser consumes. Placeholder-only per the anti-parrot pattern — no
literal UUIDs, outcomes, or reasons that could leak into unrelated
sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:17:21 -04:00
c0112f8bee feat(pilot): [FIX_OUTCOME] marker parser + AI outcome proposal
The AI emits [FIX_OUTCOME] when the engineer indicates in chat that a
prior suggested fix worked, didn't work, or was partially applied. The
marker writes to session_suggested_fixes.ai_outcome_proposal (JSONB),
which the frontend surfaces as a "confirm outcome?" banner. The status
column is only updated when the engineer clicks confirm (via PATCH
/outcome endpoint from Task 3).

Placeholder-only system prompt wiring comes in Task 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:08:43 -04:00
8988dbc885 feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests
Records engineer-reported outcome (applied_success|applied_failed|
applied_partial|dismissed). Enforces transition rules (partial → success/
failed allowed; terminal outcomes return 409) and notes requirements
(applied_partial requires notes).

Sets verified_at on success/failure, stamps applied_at if not already
set (handles the case where the AI [FIX_OUTCOME] marker fires before
the engineer clicks Apply).

Also fixes pre-existing test-infrastructure bug: network_diagram.py used
bare string server_default="'[]'" for JSONB columns, which asyncpg
rejects during test schema creation. Changed to text("'[]'::jsonb") to
match the pattern used by script_template.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:59:34 -04:00
4a8e3ae954 feat(pilot): pydantic schemas for fix outcome patch
Adds FixStatus literal (5 values matching the DB check constraint),
extends SessionSuggestedFixResponse with outcome fields, and introduces
SessionSuggestedFixOutcomeRequest for the PATCH /outcome endpoint coming
in Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:44:39 -04:00
cdd8bb05cc feat(pilot): add outcome tracking columns to session_suggested_fixes
Phase 8 prep for the fix outcome banner. Adds:
- status (proposed|applied_success|applied_failed|applied_partial|dismissed)
- applied_at, verified_at (timestamps)
- partial_notes, failure_reason (engineer-provided context)
- ai_outcome_proposal (JSONB for AI [FIX_OUTCOME] marker payloads)

Backfills status='dismissed' from user_decision='dismissed'. status is
orthogonal to user_decision — outcome (did the fix work?) vs script-path.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

22
.ai/CURRENT_TASK.md Normal file
View File

@@ -0,0 +1,22 @@
# CURRENT_TASK.md
**Task:** Restore a fully green CI gate on `main` and lock it via branch protection so future merges can't introduce silent rot.
**Status:** in-progress
**Definition of Done:**
- [ ] PR #150 (`fix/ci-workflow-config`) merged. Both `CI / backend (pull_request)` and `CI / frontend (pull_request)` show success on the merge commit.
- [ ] `CI / backend (pull_request)` added to required status checks on `main` in Gitea branch protection (frontend is already required).
- [ ] The 54 real backend test failures (left after #149's infra cleanup) categorized and fixed in a follow-up PR. Target: 0 failures, 0 errors on a `pytest` run inside `resolutionflow_backend`.
- [ ] `npm run lint` stays at 0 errors after the cleanup PR (already at 0 on main).
- [ ] Append a SESSION_LOG.md entry summarizing what shipped.
**Assumptions:**
- The 54 failures fall into a small number of root-cause categories (likely 35: fixture-scoping leaks, DB cleanup ordering, account_id propagation in test seed paths). Verify before assuming.
- The pytest-asyncio 0.24 + pytest 8.4 toolchain bumped in #149 is the right baseline; do not revert.
- `DATABASE_TEST_URL` is the only DB URL conftest will honor; do not weaken the safety guard added in `dab740d`.
**Out of scope:**
- New feature work on FlowPilot (Phase 10+) or PSA — keep this branch focused on CI debt.
- Frontend lint warnings (23 remain after #149; they're missing-deps in useEffect, opt-in cleanup later).
- RLS test suite (`test_rls_isolation.py`) — gated behind `RUN_RLS_TESTS=1` and not in the default CI run.

31
.ai/DECISIONS.md Normal file
View File

@@ -0,0 +1,31 @@
# 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-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`.

63
.ai/HANDOFF.md Normal file
View File

@@ -0,0 +1,63 @@
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
# HANDOFF.md
**Last updated:** 2026-04-25 06:12 EDT
**Active task:** Restore green CI gate on `main` and lock it via branch protection. See [CURRENT_TASK.md](CURRENT_TASK.md).
**Branch:** `fix/ci-workflow-config`
## Current state
Previous session fixed the 54 real backend failures left after #149. The default backend suite is now green locally:
```bash
docker exec resolutionflow_backend bash -lc 'pytest --override-ini="addopts=" -q > /tmp/full-backend.log 2>&1; code=$?; tail -n 160 /tmp/full-backend.log; exit $code'
# 1076 passed, 35 deselected in 1347.41s (0:22:27)
```
Targeted validation also passed:
- `tests/test_session_resolutions_api.py tests/test_session_sharing.py tests/test_session_suggested_fixes_api.py tests/test_survey.py tests/test_tenant_isolation_p0.py tests/test_tree_sharing.py tests/test_trees.py::TestTrees::test_delete_tree_cleans_up_folder_and_tag_assignments tests/test_uploads.py::test_delete_upload_forbidden_for_non_owner``73 passed`
- PDF export tests → `3 passed`
- Prompt/PSA/resolution/script-builder subset → `14 passed`
- Admin/AI/branch subsets → `11 passed`
## What changed
Production fixes:
- CI/backend dev image now installs WeasyPrint system libraries.
- Public share-token and survey routes are mounted outside tenant auth; protected share management remains tenant-protected.
- Folder creation now persists `UserFolder.account_id`.
- Script Builder save-to-library now persists `ScriptTemplate.account_id`.
- Resolution output generation eager-loads `AISession.steps` to avoid async lazy-load `MissingGreenlet`.
- AI session model now declares the generated `search_vector` column already present in Alembic, so `create_all` test schemas match runtime migrations.
- Direct account-role update now rejects `"owner"`; ownership changes must use the transfer path.
- Assistant prompt marker examples no longer include a literal executable `create_spin_off_ticket` payload.
Test/harness fixes:
- Test seeds updated for tenant-scoped `account_id` columns on sessions, branches, resolution outputs, script templates, PSA connections, folders, schedules, and categories.
- Tests aligned with 404-not-403 resource-hiding policy.
- Disabled-AI tests now restore both Anthropic and Google key settings.
- Pytest harness closes pytest-asyncio's leftover clean loop and ignores known unclosed asyncio/asyncpg teardown ResourceWarnings that otherwise appear at arbitrary later setup points under `filterwarnings = error`.
## Immediate next steps
1. Commit current working tree if not already committed with trailer:
`Co-Authored-By: Codex <noreply@openai.com>`.
2. Check PR #150 status on Gitea. If both `CI / backend (pull_request)` and `CI / frontend (pull_request)` are green, merge it.
3. After #150 merges, add `CI / backend (pull_request)` to required status checks on main:
```bash
PATCH /repos/chihlasm/resolutionflow/branch_protections/main
{ "status_check_contexts": ["CI / frontend (pull_request)", "CI / backend (pull_request)"] }
```
`$GITEA_TOKEN` is in `.claude/settings.local.json`.
4. Run/confirm frontend lint if needed for the final DoD item (`npm run lint` was already green after #149, but this session did not rerun it).
## Open questions
- PR #150 was not rechecked or merged in this session.
- Branch protection was not updated in this session.

254
.ai/PROJECT_CONTEXT.md Normal file
View File

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

42
.ai/README.md Normal file
View File

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

46
.ai/SESSION_LOG.md Normal file
View File

@@ -0,0 +1,46 @@
# SESSION_LOG.md
> Append-only chronological record. Newest entries at the top. Skim when broader context is needed.
> Entry format:
>
> ```
> ## YYYY-MM-DD HH:MM <timezone> — <agent> — <one-line summary>
> - What was accomplished
> - What was left for next session
> - Files touched
> ```
---
## 2026-04-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.

13
.ai/TODO.md Normal file
View File

@@ -0,0 +1,13 @@
# TODO.md
> Backlog of work NOT currently active. Read only when `CURRENT_TASK.md` status is `complete`.
> Format: `- [ ] short description — optional link to issue/PR`
## Up next
- [ ] **Parallelize backend pytest with pytest-xdist.** Currently the backend suite takes ~22 min wall-clock for `1076 passed, 35 deselected` (verified locally 2026-04-25). With `-n auto` on the homelab Gitea Actions runner, this should land in the 36 min range depending on core count. Blocker: `test_db` fixture in `backend/tests/conftest.py` does `DROP SCHEMA public CASCADE` per test, which two workers would race on. Standard fix: one database per worker, derived from `PYTEST_XDIST_WORKER` env var inside conftest. The runner has spare CPU, so prioritize once main is green and the 54-failure cleanup has landed.
## Backlog
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.

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

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

15
.claude/settings.json Normal file
View File

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

View File

@@ -28,6 +28,12 @@ jobs:
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
# not consulted after the dab740d test-isolation hardening). The CI test
# DB is the same postgres service, so point DATABASE_TEST_URL at it
# explicitly — without this, conftest falls back to localhost:5432 and
# all tests fail at fixture setup with "connection refused".
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
@@ -37,6 +43,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: Install system dependencies
run: |
apt-get update
apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev
- name: Install dependencies
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
@@ -47,7 +66,15 @@ jobs:
run: cd backend && python scripts/check_tenant_filters.py
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
# `-n auto` parallelizes across all runner cores via pytest-xdist.
# conftest.py creates a per-worker DB (resolutionflow_test_gw0,
# resolutionflow_test_gw1, …) so the per-test DROP SCHEMA doesn't
# race across workers. Master/serial runs keep the base DB.
# term-missing dropped — the custom "Display coverage summary" step
# below parses coverage.json and prints the same info more concisely.
# --maxfail=10 short-circuits on structural breakage so we don't burn
# 25 minutes when a fixture explodes.
run: cd backend && python -m pytest --override-ini="addopts=" -n auto --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
- name: Display coverage summary
if: always()
@@ -75,6 +102,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: cd frontend && npm ci
@@ -88,7 +123,7 @@ jobs:
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: frontend-dist
path: frontend/dist
@@ -125,6 +160,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
restore-keys: |
pip-${{ runner.os }}-
- name: 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
@@ -132,7 +183,7 @@ jobs:
run: cd frontend && npm ci
- name: Download frontend build
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: frontend-dist
path: frontend/dist
@@ -145,7 +196,7 @@ jobs:
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |

9
.gitignore vendored
View File

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

61
AGENTS.md Normal file
View File

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

View File

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

267
CLAUDE.md
View File

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

View File

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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0.0

View File

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

View File

@@ -0,0 +1,74 @@
"""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")

View File

@@ -0,0 +1,70 @@
"""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")

View File

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

View File

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

View File

@@ -3,12 +3,14 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
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 (
@@ -67,15 +69,85 @@ 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."""
"""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 ──────────────────────────────────────────────────
# 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
count = await script_builder_service.count_user_sessions(db, current_user.id)
# Enforce max concurrent sessions (inline sessions excluded from cap)
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
if count >= MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=400,
@@ -88,6 +160,8 @@ 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
@@ -186,6 +260,7 @@ async def save_to_library(
category_id=data.category_id,
share_with_team=data.share_with_team,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
script_body=data.script_body,
parameters_schema=data.parameters_schema,

View File

@@ -30,7 +30,9 @@ from app.schemas.session_suggested_fix import (
ResolutionPostResponse,
SessionSuggestedFixDecisionRequest,
SessionSuggestedFixDecisionResponse,
SessionSuggestedFixOutcomeRequest,
SessionSuggestedFixResponse,
SessionSuggestedFixScriptRequest,
)
from app.models.draft_template import DraftTemplate
from app.models.session_fact import SessionFact
@@ -216,6 +218,240 @@ async def record_decision(
)
# ── 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",
)
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_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:

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
@@ -30,8 +30,8 @@ class NetworkDiagram(Base):
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="'[]'")
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
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,

View File

@@ -62,6 +62,16 @@ 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)
)

View File

@@ -35,6 +35,11 @@ class SessionSuggestedFix(Base):
"'one_off', 'draft_template', 'build_template', 'dismissed')",
name="ck_session_suggested_fixes_user_decision",
),
CheckConstraint(
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
name="ck_session_suggested_fixes_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -65,6 +70,21 @@ class SessionSuggestedFix(Base):
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)
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

View File

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

View File

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

View File

@@ -1,18 +1,27 @@
"""Pydantic schemas for the AI Script Builder."""
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class ScriptBuilderCreateRequest(BaseModel):
"""Request to start a new builder session."""
"""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.
"""
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):

View File

@@ -12,6 +12,17 @@ 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",
"dismissed",
]
class SessionSuggestedFixResponse(BaseModel):
id: UUID
@@ -25,6 +36,12 @@ class SessionSuggestedFixResponse(BaseModel):
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
failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None
model_config = {"from_attributes": True}
@@ -71,6 +88,43 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
)
# Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[
"applied_success", "applied_failed", "applied_partial", "dismissed"
]
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` or `applied_partial`: any outcome is valid
(partial is parked, not terminal — the engineer may update notes,
abandon via dismiss, or advance to success/failed)
- from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409
"""
outcome: FixOutcome
# Required for applied_partial, optional for applied_failed, ignored otherwise.
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):

View File

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

View File

@@ -198,6 +198,44 @@ for the drafted script
The marker is stripped from display — the engineer sees the suggested fix as \
an interactive card with confidence badge, not raw JSON.
## Reporting fix outcome with [FIX_OUTCOME]
When the engineer clearly indicates in chat that a previously proposed fix
worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker
on its own lines. This surfaces a "confirm outcome?" banner in the UI — it
does NOT mark the fix resolved on its own; the engineer confirms via the UI.
**When to emit [FIX_OUTCOME]:**
- The engineer states the user's problem is resolved after applying the fix
(affirmative resolution language → outcome="success")
- The engineer states the issue persists after applying the fix
(→ outcome="failure")
- The engineer describes applying only part of the fix
(→ outcome="partial")
**When NOT to emit [FIX_OUTCOME]:**
- The engineer is still verifying (user rebooting, testing, etc.)
- The outcome is ambiguous or inferred rather than stated
- No [SUGGEST_FIX] has been emitted this session
**[FIX_OUTCOME] marker format (one block per response, on its own lines).**
Schema below — DO NOT copy these placeholders into your real response, fill \
each field with content specific to the actual ticket:
[FIX_OUTCOME]
{"fix_id": "<uuid-of-the-active-suggested-fix>",
"outcome": "<success|failure|partial>",
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
[/FIX_OUTCOME]
- `fix_id`: the UUID of the active suggested fix (provided in session context)
- `outcome`: one of `"success"`, `"failure"`, or `"partial"`
- `reason`: one-line paraphrase of what the engineer said — derived from \
their CURRENT message, not invented
The marker is stripped from display — the engineer sees a "confirm outcome?" \
banner in the UI, not raw JSON.
## Using the Team's Flow Library
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
appear in the context below, reference them by name so the engineer can launch them \
@@ -257,6 +295,24 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
- If a question is clearly outside your domain, say so briefly and redirect.
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
## SPIN-OFF TICKET CREATION
When you identify a second distinct issue that is clearly separate from the primary topic \
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
Use `create_spin_off_ticket` as the command value for this action.
Format:
[ACTIONS]
[
{
"label": "Create ticket: <brief issue title>",
"command": "<spin-off ticket action command>",
"description": "<one sentence description of the separate issue>"
}
]
[/ACTIONS]
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \
@@ -269,6 +325,8 @@ the originating item's `id` into `source_ref` verbatim.
[SUGGEST_FIX] is OPTIONAL — emit one at most per response, only when you have a \
concrete proposed resolution at ~50%+ confidence. A new [SUGGEST_FIX] supersedes \
any prior suggested fix.
[FIX_OUTCOME] is OPTIONAL — emit one at most per response, only when the engineer \
has clearly stated the outcome in their current message.
ANTI-PARROT RULE: The schemas above use placeholders in `<angle brackets>` to show \
the SHAPE of valid output. Your real questions, actions, facts, and suggested fixes \

View File

@@ -55,22 +55,45 @@ header.>
If there are no facts, write "Nothing confirmed yet." and continue.>
## What we've tried
<bulleted list of diagnostic checks run (from the [diagnostic_check] facts) \
and scripts generated during the session. State what each revealed or did, \
not what was attempted without an outcome. If nothing has been tried, write \
"No diagnostic actions run yet." and continue.>
<Bulleted list of diagnostic checks run and scripts generated during the \
session. The content of this section also depends on the outcome recorded for \
the active suggested fix, as given in the input bundle under "Outcome status":>
- applied_failed: List the fix as a tried path. Include the failure reason if \
provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified.
- applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered.
- proposed (no outcome yet): Do not list it here; it goes in Current hypothesis.
If nothing has been tried at all (no checks, no scripts, no applied/partial \
fix), write "No diagnostic actions run yet." and continue.
## Current hypothesis
<one short paragraph naming the active suggested fix and its confidence. If \
confidence is below 60% or there is no active fix, say so plainly: "No leading \
hypothesis yet — symptoms are still being narrowed.">
<The content depends on the outcome recorded for the active suggested fix:>
- proposed (no outcome yet): State the fix title and confidence. If confidence \
is below 60% or there is no active fix, say "No leading hypothesis yet — \
symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open.
- applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation.
## Suggested next steps
<bulleted list of 2-4 concrete next actions the receiving engineer should \
take. Prefer specifics: commands to run, tickets to check, people to contact. \
Derive from the gap between confirmed facts and a complete resolution. If the \
active suggested fix is high confidence (>80%), the first bullet is "Try the \
suggested fix: <title>.">
Derive from the gap between confirmed facts and a complete resolution. \
If the active suggested fix failed (applied_failed), inform the next steps \
accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \
If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).>
Strict rules:
- Use ONLY the input I provide. Never invent command names, KB articles, or \
@@ -269,6 +292,15 @@ class EscalationPackageGeneratorService:
lines.append(f"Title: {active_fix.title}")
lines.append(f"Confidence: {active_fix.confidence_pct}%")
lines.append(f"Description: {active_fix.description}")
lines.append(f"Outcome status: {active_fix.status}")
if active_fix.applied_at:
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
if active_fix.verified_at:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
lines.append("")
lines.append(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,11 +69,24 @@ say "Root cause not definitively isolated." and explain what is suspected based
on facts.>
## Resolution
<one short paragraph describing the resolution applied. If a script ran during \
the session, mention it (e.g. "Cleared cached credentials via the \
clear-outlook-credentials script."). If no resolution has been performed yet, \
write "Resolution not yet applied — fix proposed: <fix title>." Pull verbatim \
script names and template references when available.>
<The content of this section depends on the outcome recorded for the active \
suggested fix, as given in the input bundle under "fix.status":>
- applied_success: Write in past tense using closure language. State that the \
fix was applied and verified as working. If verified_at is provided, you may \
reference it as the time resolution was confirmed. Example phrasing: \
"Applied <fix title>; confirmed working."
- applied_failed: Acknowledge that the proposed fix did not resolve the issue \
and was discarded. If failure_reason is provided, include it. Then describe \
the actual resolution path taken (derived from facts and scripts run). This \
state means the engineer resolved the issue another way; the note should cover \
that actual resolution, not just the failed attempt.
- applied_partial: Note that the fix was partially applied. If partial_notes \
are provided, include them. Then describe the final resolution path taken.
- dismissed: Treat the fix as considered and set aside. Do not center the note \
on it. Describe the resolution based on what was actually confirmed and done.
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
<fix title>." Pull verbatim script names and template references when available.
Strict rules:
- Use ONLY the facts and state I provide. Never invent specifics that are not \
@@ -302,6 +315,15 @@ class ResolutionNoteGeneratorService:
lines.append(f"Description: {active_fix.description}")
if active_fix.user_decision:
lines.append(f"Engineer decision: {active_fix.user_decision}")
lines.append(f"Outcome status: {active_fix.status}")
if active_fix.applied_at:
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
if active_fix.verified_at:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
lines.append("")
lines.append("# Scripts run during the session (passwords redacted)")

View File

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

View File

@@ -148,6 +148,8 @@ async def create_session(
team_id: UUID | None,
language: str,
initial_prompt: str | None = None,
origin: str = "standalone",
ai_session_id: UUID | None = None,
) -> ScriptBuilderSession:
"""Create a new Script Builder session."""
session = ScriptBuilderSession(
@@ -155,6 +157,8 @@ async def create_session(
account_id=account_id,
team_id=team_id,
language=language,
origin=origin,
ai_session_id=ai_session_id,
)
db.add(session)
await db.flush()
@@ -295,15 +299,22 @@ async def list_sessions(
user_id: UUID,
limit: int = 20,
offset: int = 0,
*,
include_inline: bool = False,
) -> list[ScriptBuilderSession]:
"""List user's builder sessions ordered by updated_at desc."""
result = await db.execute(
"""List user's builder sessions ordered by updated_at desc.
By default (include_inline=False) excludes pilot_inline sessions so the
/script-builder dashboard only shows standalone sessions.
"""
stmt = (
select(ScriptBuilderSession)
.where(ScriptBuilderSession.user_id == user_id)
.order_by(ScriptBuilderSession.updated_at.desc())
.limit(limit)
.offset(offset)
)
if not include_inline:
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
return list(result.scalars().all())
@@ -321,13 +332,23 @@ async def delete_session(
return True
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
"""Count active builder sessions for a user."""
result = await db.execute(
select(func.count(ScriptBuilderSession.id)).where(
ScriptBuilderSession.user_id == user_id
)
async def count_user_sessions(
db: AsyncSession,
user_id: UUID,
*,
include_inline: bool = False,
) -> int:
"""Count active builder sessions for a user.
By default (include_inline=False) excludes pilot_inline sessions so they
don't consume slots against the MAX_SESSIONS_PER_USER cap.
"""
stmt = select(func.count(ScriptBuilderSession.id)).where(
ScriptBuilderSession.user_id == user_id
)
if not include_inline:
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
result = await db.execute(stmt)
return result.scalar_one()
@@ -339,6 +360,7 @@ async def save_to_library(
category_id: UUID | None,
share_with_team: bool,
user_id: UUID,
account_id: UUID,
team_id: UUID | None,
script_body: str | None = None,
parameters_schema: dict | None = None,
@@ -380,6 +402,7 @@ async def save_to_library(
id=uuid_mod.uuid4(),
category_id=resolved_category_id,
created_by=user_id,
account_id=account_id,
team_id=team_id if share_with_team else None,
name=name,
slug=slug,

View File

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

View File

@@ -354,6 +354,56 @@ def _parse_suggest_fix_marker(
return cleaned, parsed
def _parse_fix_outcome_marker(
ai_content: str,
) -> tuple[str, dict[str, Any] | None]:
"""Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block.
Block shape:
{"fix_id": "<uuid>", "outcome": "success"|"failure"|"partial",
"reason": "<one-line>"}
Emitted by the AI when the engineer clearly indicates in chat that a
prior suggested fix worked, didn't work, or was partially applied.
The marker PROPOSES an outcome — the engineer confirms via the UI.
Only the last block in a response is honored.
"""
blocks = list(re.finditer(
r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content,
))
if not blocks:
return ai_content, None
last = blocks[-1]
raw = last.group(1).strip()
if raw.startswith("```"):
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
cleaned = re.sub(
r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content,
).strip()
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e)
return cleaned, None
if not isinstance(data, dict):
return cleaned, None
fix_id = str(data.get("fix_id") or "").strip()
outcome = str(data.get("outcome") or "").strip().lower()
reason = str(data.get("reason") or "").strip()
if not fix_id or outcome not in {"success", "failure", "partial"}:
logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping")
return cleaned, None
return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason}
async def _persist_suggested_fix(
*,
db: AsyncSession,
@@ -415,6 +465,39 @@ async def _persist_suggested_fix(
await db.flush()
async def _record_ai_outcome_proposal(
*,
db: AsyncSession,
session: AISession,
proposal: dict[str, Any],
) -> None:
"""Persist the AI's proposed outcome on the active fix.
Writes to session_suggested_fixes.ai_outcome_proposal. Frontend polls
the active fix and renders the AI-confirming banner state when this is
non-null. Does NOT mutate the fix's status — the engineer's confirmation
click via PATCH /outcome is what changes the status.
Drops silently when the fix_id isn't a valid UUID or doesn't belong to
this session.
"""
try:
fix_uuid = UUID(proposal["fix_id"])
except (ValueError, KeyError, TypeError):
logger.warning("[FIX_OUTCOME] invalid fix_id, dropping")
return
await db.execute(
update(SessionSuggestedFix)
.where(
SessionSuggestedFix.id == fix_uuid,
SessionSuggestedFix.session_id == session.id,
)
.values(ai_outcome_proposal=proposal)
)
await db.flush()
async def _persist_promote_items(
*,
db: AsyncSession,
@@ -566,6 +649,7 @@ async def send_chat_message(
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
branch_display, branch_promote_items = _parse_promote_marker(branch_display)
branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display)
branch_display, branch_outcome_proposal = _parse_fix_outcome_marker(branch_display)
if branch_display != ai_content:
# Store stripped content in branch history
msgs[-1] = {"role": "assistant", "content": branch_display}
@@ -629,6 +713,12 @@ async def send_chat_message(
db=db, session=session, fix=branch_suggest_fix,
)
# Persist a [FIX_OUTCOME] proposal if the branch turn included one.
if branch_outcome_proposal is not None:
await _record_ai_outcome_proposal(
db=db, session=session, proposal=branch_outcome_proposal,
)
suggested_flows = extract_suggested_flows(
await rag_search(query=message, account_id=account_id, db=db, limit=8)
)
@@ -681,11 +771,16 @@ async def send_chat_message(
# Check for a [SUGGEST_FIX] marker — supersedes the prior active fix.
display_content, suggest_fix_data = _parse_suggest_fix_marker(display_content)
# Check for a [FIX_OUTCOME] proposal — AI confirms a prior fix's outcome.
display_content, outcome_proposal = _parse_fix_outcome_marker(display_content)
logger.info(
"Marker parsing results — actions: %s, questions: %s, fork: %s, "
"promote: %d, suggest_fix: %s, raw_length: %d, display_length: %d",
"promote: %d, suggest_fix: %s, outcome_proposal: %s, "
"raw_length: %d, display_length: %d",
bool(actions_data), bool(questions_data), bool(fork_data),
len(promote_items or []), bool(suggest_fix_data),
bool(outcome_proposal),
len(ai_content), len(display_content),
)
@@ -774,6 +869,12 @@ async def send_chat_message(
if suggest_fix_data:
await _persist_suggested_fix(db=db, session=session, fix=suggest_fix_data)
# Persist a [FIX_OUTCOME] proposal if this turn included one.
if outcome_proposal is not None:
await _record_ai_outcome_proposal(
db=db, session=session, proposal=outcome_proposal,
)
suggested_flows = extract_suggested_flows(rag_results)
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,536 @@
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.
Fixture style follows test_session_suggested_fixes_api.py:
client, test_user, auth_headers, test_db
"""
from __future__ import annotations
from unittest.mock import AsyncMock, call, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix
@pytest.fixture(autouse=True)
def _isolate_preview_cache():
_clear_preview_cache_for_tests()
yield
_clear_preview_cache_for_tests()
# ── shared helper ────────────────────────────────────────────────────────────
async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
"""Create an AISession + active proposed SessionSuggestedFix.
Returns (session_id_str, fix_id_str).
"""
session = AISession(
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "outcome test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
session_id=session.id,
account_id=session.account_id,
title="Reset credential cache",
description="Clear stale credentials from the domain cache.",
confidence_pct=82,
)
test_db.add(fix)
await test_db.commit()
await test_db.refresh(fix)
return str(session.id), str(fix.id)
# ── tests ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_patch_outcome_marks_success(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "applied_success"
assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_patch_outcome_partial_requires_notes(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_partial"},
)
assert r.status_code == 400
assert "notes" in r.text.lower()
@pytest.mark.asyncio
async def test_partial_to_success_allowed(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
)
assert r1.status_code == 200, r1.text
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 200
assert r2.json()["status"] == "applied_success"
@pytest.mark.asyncio
async def test_terminal_outcome_is_locked(
client: AsyncClient, test_user, auth_headers, test_db
):
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_failed", "notes": "no change"},
)
assert r1.status_code == 200
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 409
@pytest.mark.asyncio
async def test_partial_notes_can_be_updated(
client: AsyncClient, test_user, auth_headers, test_db
):
"""partial→partial with new notes updates the stored notes."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
headers=auth_headers,
)
assert r1.status_code == 200
assert r1.json()["partial_notes"] == "ran cred clear only"
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
headers=auth_headers,
)
assert r2.status_code == 200
assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"
@pytest.mark.asyncio
async def test_dismissed_sets_no_timestamps(
client: AsyncClient, test_user, auth_headers, test_db
):
"""dismissed outcome does not stamp applied_at or verified_at."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "dismissed"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "dismissed"
assert body["applied_at"] is None
assert body["verified_at"] is None
@pytest.mark.asyncio
async def test_applied_at_auto_stamped_on_first_outcome(
client: AsyncClient, test_user, auth_headers, test_db
):
"""If applied_at is null when the engineer sets outcome, server stamps it."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_success"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["applied_at"] is not None
assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_failed stores notes under failure_reason (not partial_notes)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_failed", "notes": "user reports no change"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert body["failure_reason"] == "user reports no change"
assert body["partial_notes"] is None
# ── state_version bump ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_outcome_patch_bumps_state_version(
client: AsyncClient, test_user, auth_headers, test_db
):
"""PATCH /outcome must increment ai_sessions.state_version (like record_decision)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Capture the initial state_version from DB.
from uuid import UUID
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
initial_version = session_obj.state_version
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_success"},
headers=auth_headers,
)
assert r.status_code == 200
await test_db.refresh(session_obj)
assert session_obj.state_version == initial_version + 1, (
"Outcome patch must bump state_version so preview cache is invalidated"
)
# ── outcome propagation into preview bundle ───────────────────────────────────
@pytest.mark.asyncio
async def test_resolution_note_preview_reflects_outcome_after_patch(
client: AsyncClient, test_user, auth_headers, test_db
):
"""End-to-end: preview before outcome != preview after outcome; new preview
bundle includes failure_reason; state_version was bumped between the two.
The LLM is stubbed so the test is deterministic. The stub returns whatever
the user-message content is, which means the captured call args reflect
what the bundle actually contained.
"""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
distinct_failure_reason = "DISTINCT-FAILURE-REASON-XYZZY-42"
calls_made: list[str] = []
async def fake_generate_text(system_prompt, messages, max_tokens):
user_content = messages[0]["content"]
calls_made.append(user_content)
# Return markdown that includes the user-message bundle verbatim so we
# can assert the bundle shape without inspecting mock internals.
return (
f"## Problem\ntest\n\n## What we confirmed\n(none)\n\n"
f"## Root cause\ntest\n\n## Resolution\nBUNDLE_CONTENT={user_content}",
100,
50,
)
fake_provider = AsyncMock()
fake_provider.generate_text = AsyncMock(side_effect=fake_generate_text)
with patch(
"app.services.resolution_note_generator.get_ai_provider",
return_value=fake_provider,
):
# Preview A — before any outcome recorded (status = "proposed").
r_a = await client.post(
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
headers=auth_headers,
)
assert r_a.status_code == 200
markdown_a = r_a.json()["markdown"]
version_a = r_a.json()["state_version"]
assert r_a.json()["from_cache"] is False
# Record an applied_failed outcome with a distinctive reason.
r_patch = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_failed", "notes": distinct_failure_reason},
headers=auth_headers,
)
assert r_patch.status_code == 200
# Preview B — must be a cache miss because state_version changed.
r_b = await client.post(
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
headers=auth_headers,
)
assert r_b.status_code == 200
markdown_b = r_b.json()["markdown"]
version_b = r_b.json()["state_version"]
assert r_b.json()["from_cache"] is False, (
"Preview after outcome patch must be a cache miss (state_version changed)"
)
# State version increased between the two previews.
assert version_b > version_a, (
f"state_version should have increased; got {version_a}{version_b}"
)
# Markdown differs between the two previews.
assert markdown_a != markdown_b, (
"Regenerated preview after outcome patch should differ from pre-outcome preview"
)
# The bundle passed to the LLM for preview B includes the outcome fields.
assert len(calls_made) == 2, f"Expected 2 LLM calls (one per preview); got {len(calls_made)}"
bundle_b = calls_made[1]
assert "applied_failed" in bundle_b, (
"Bundle for second preview should include 'Outcome status: applied_failed'"
)
assert distinct_failure_reason in bundle_b, (
"Bundle for second preview should include the failure_reason text"
)
# ── Apply endpoint ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_apply_stamps_applied_at(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply stamps applied_at and bumps state_version."""
from uuid import UUID
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
initial_version = session_obj.state_version
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["applied_at"] is not None, "applied_at must be set after /apply"
assert body["status"] == "proposed", "status must remain 'proposed' after /apply"
await test_db.refresh(session_obj)
assert session_obj.state_version == initial_version + 1, (
"/apply must bump state_version so preview cache is invalidated"
)
@pytest.mark.asyncio
async def test_apply_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
from uuid import UUID
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r1.status_code == 200, r1.text
applied_at_first = r1.json()["applied_at"]
result = await test_db.execute(
select(AISession).where(AISession.id == UUID(session_id))
)
session_obj = result.scalar_one()
version_after_first = session_obj.state_version
r2 = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r2.status_code == 200, r2.text
assert r2.json()["applied_at"] == applied_at_first, (
"applied_at must not change on second /apply call"
)
await test_db.refresh(session_obj)
assert session_obj.state_version == version_after_first, (
"state_version must not be bumped a second time on idempotent /apply"
)
@pytest.mark.asyncio
async def test_apply_rejects_non_proposed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply returns 409 when fix status is 'applied_success'."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Advance the fix to a terminal status via the outcome endpoint.
r_outcome = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r_outcome.status_code == 200
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 409, r.text
@pytest.mark.asyncio
async def test_apply_rejects_dismissed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""POST /apply returns 409 when fix status is 'dismissed'."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r_outcome = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "dismissed"},
)
assert r_outcome.status_code == 200
r = await client.post(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
headers=auth_headers,
)
assert r.status_code == 409, r.text
# ── AI outcome proposal: clear / reject ───────────────────────────────────────
async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]:
"""Create an AISession + fix with a populated ai_outcome_proposal."""
from uuid import UUID as _UUID
session = AISession(
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="chat",
intake_type="free_text",
intake_content={"text": "proposal clear test"},
status="active",
confidence_tier="discovery",
conversation_messages=[],
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
session_id=session.id,
account_id=session.account_id,
title="Flush DNS cache",
description="Run ipconfig /flushdns on the affected host.",
confidence_pct=74,
ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"},
)
test_db.add(fix)
await test_db.commit()
await test_db.refresh(fix)
return str(session.id), str(fix.id)
@pytest.mark.asyncio
async def test_outcome_patch_clears_ai_proposal(
client: AsyncClient, test_user, auth_headers, test_db
):
"""PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written."""
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
# Verify the proposal is set before the patch.
from uuid import UUID
result = await test_db.execute(
select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id))
)
fix_before = result.scalar_one()
assert fix_before.ai_outcome_proposal is not None
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_outcome_proposal"] is None, (
"PATCH /outcome must clear ai_outcome_proposal on any terminal action"
)
@pytest.mark.asyncio
async def test_delete_ai_proposal_clears_field(
client: AsyncClient, test_user, auth_headers, test_db
):
"""DELETE /ai-outcome-proposal clears the field without changing status."""
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
r = await client.delete(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_outcome_proposal"] is None, (
"DELETE /ai-outcome-proposal must clear the field"
)
assert body["status"] == "proposed", (
"DELETE /ai-outcome-proposal must not change fix status"
)
@pytest.mark.asyncio
async def test_delete_ai_proposal_when_none_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""DELETE /ai-outcome-proposal returns 200 even when the field is already null."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
# Fix created by _make_session_with_fix has ai_outcome_proposal=None.
r = await client.delete(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
headers=auth_headers,
)
assert r.status_code == 200, r.text
assert r.json()["ai_outcome_proposal"] is None

View File

@@ -0,0 +1,91 @@
"""Unit tests for the [FIX_OUTCOME] marker parser."""
from __future__ import annotations
from app.services.unified_chat_service import _parse_fix_outcome_marker
def test_parses_success_outcome():
ai = (
"Great news — that confirms the root cause.\n\n"
"[FIX_OUTCOME]\n"
'{"fix_id":"11111111-1111-1111-1111-111111111111",'
'"outcome":"success","reason":"user said the fix worked"}\n'
"[/FIX_OUTCOME]\n"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert "confirms the root cause" in cleaned
assert parsed == {
"fix_id": "11111111-1111-1111-1111-111111111111",
"outcome": "success",
"reason": "user said the fix worked",
}
def test_parses_failure_outcome():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"22222222-2222-2222-2222-222222222222",'
'"outcome":"failure","reason":"user reports still broken"}\n'
"[/FIX_OUTCOME]"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed["outcome"] == "failure"
def test_missing_marker_returns_none():
ai = "no marker here"
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert cleaned == ai
assert parsed is None
def test_invalid_json_is_dropped():
ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]"
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed is None
def test_unknown_outcome_rejected():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"33333333-3333-3333-3333-333333333333",'
'"outcome":"maybe","reason":"x"}\n'
"[/FIX_OUTCOME]"
)
_, parsed = _parse_fix_outcome_marker(ai)
assert parsed is None
def test_last_block_wins_when_multiple():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"44444444-4444-4444-4444-444444444444",'
'"outcome":"failure","reason":"first"}\n'
"[/FIX_OUTCOME]\n"
"[FIX_OUTCOME]\n"
'{"fix_id":"55555555-5555-5555-5555-555555555555",'
'"outcome":"success","reason":"second"}\n'
"[/FIX_OUTCOME]"
)
cleaned, parsed = _parse_fix_outcome_marker(ai)
assert "[FIX_OUTCOME]" not in cleaned
assert parsed["fix_id"] == "55555555-5555-5555-5555-555555555555"
assert parsed["outcome"] == "success"
def test_parses_partial_outcome():
ai = (
"[FIX_OUTCOME]\n"
'{"fix_id":"66666666-6666-6666-6666-666666666666",'
'"outcome":"partial","reason":"user ran cred clear only"}\n'
"[/FIX_OUTCOME]"
)
_, parsed = _parse_fix_outcome_marker(ai)
assert parsed == {
"fix_id": "66666666-6666-6666-6666-666666666666",
"outcome": "partial",
"reason": "user ran cred clear only",
}

View File

@@ -0,0 +1,120 @@
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from uuid import UUID, uuid4
from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix
async def _make_session_with_fix(
test_db, user, *, status: str = "proposed", with_script: bool = False,
) -> tuple[str, str]:
"""Create a pilot session + suggested fix for tests. Returns (sid, fid)."""
session = AISession(
id=uuid4(),
user_id=user["user_data"]["id"],
account_id=user["user_data"]["account_id"],
session_type="tshoot",
intake_type="psa_ticket",
intake_content={},
title="QA",
status="active",
confidence_tier="exploring",
confidence_score=0.0,
)
test_db.add(session)
await test_db.flush()
fix = SessionSuggestedFix(
id=uuid4(),
session_id=session.id,
account_id=user["user_data"]["account_id"],
title="QA: test fix",
description="desc",
confidence_pct=80,
status=status,
ai_drafted_script="pre-existing" if with_script else None,
)
test_db.add(fix)
await test_db.commit()
return str(session.id), str(fix.id)
@pytest.mark.asyncio
async def test_patch_script_happy_path(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "Write-Host 'hello'"},
headers=auth_headers,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ai_drafted_script"] == "Write-Host 'hello'"
assert body["applied_at"] is None # draft != apply
assert body["status"] == "proposed"
@pytest.mark.asyncio
async def test_patch_script_bumps_state_version(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
before = await test_db.scalar(
select(AISession.state_version).where(AISession.id == UUID(sid))
)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 200
after = await test_db.scalar(
select(AISession.state_version).where(AISession.id == UUID(sid))
)
assert after == (before or 0) + 1
@pytest.mark.asyncio
async def test_patch_script_rejects_terminal_fix(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success")
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 409
@pytest.mark.asyncio
async def test_patch_script_rejects_empty_body(
client: AsyncClient, test_user, auth_headers, test_db
):
sid, fid = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": ""},
headers=auth_headers,
)
assert r.status_code == 422 # pydantic min_length=1
@pytest.mark.asyncio
async def test_patch_script_404_on_wrong_session(
client: AsyncClient, test_user, auth_headers, test_db
):
_, fid = await _make_session_with_fix(test_db, test_user)
wrong_sid = str(uuid4())
r = await client.patch(
f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script",
json={"ai_drafted_script": "echo hi"},
headers=auth_headers,
)
assert r.status_code == 404

View File

@@ -87,7 +87,7 @@ _FORBIDDEN_LITERAL_TOKENS: tuple[str, ...] = (
# so prose blocks (like the closing-tag-distance regex match across
# markdown headings) are excluded
_MARKER_BLOCK_RE = re.compile(
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|FIX_OUTCOME|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
r"\s*\n" # forced newline before content
r"(\s*[\[{][\s\S]*?)" # content must start with [ or {
r"\s*\n\[/\1\]"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,176 @@
"""Integration tests for inline pilot_inline script_builder_session behavior.
Covers:
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
- Authorization: ai_session_id must belong to current user
- list_sessions + count_user_sessions default-scope to 'standalone'
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select, func
from uuid import uuid4
from app.models.ai_session import AISession
from app.models.script_builder_session import ScriptBuilderSession
async def _make_pilot_session(test_db, user) -> str:
"""Helper: create a minimal pilot session owned by `user`.
Matches the existing pattern used by test_fix_outcome_endpoint.py.
`user` is the dict returned by the test_user fixture:
{"email": ..., "password": ..., "user_data": {"id": ..., "account_id": ..., ...}}
"""
user_id = user["user_data"]["id"]
account_id = user["user_data"]["account_id"]
session = AISession(
id=uuid4(), user_id=user_id, account_id=account_id,
session_type="tshoot", intake_type="psa_ticket",
intake_content={}, title="QA",
status="active", confidence_tier="exploring", confidence_score=0.0,
)
test_db.add(session)
await test_db.commit()
return str(session.id)
@pytest.mark.asyncio
async def test_inline_create_is_idempotent(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Second create with same (user, ai_session_id) returns the existing row."""
ai_session_id = await _make_pilot_session(test_db, test_user)
r1 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r1.status_code in (200, 201), r1.text
first_id = r1.json()["id"]
r2 = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r2.status_code in (200, 201)
assert r2.json()["id"] == first_id
# DB confirms only one row
row_count = await test_db.scalar(
select(func.count()).select_from(ScriptBuilderSession).where(
ScriptBuilderSession.user_id == test_user["user_data"]["id"],
ScriptBuilderSession.origin == "pilot_inline",
)
)
assert row_count == 1
@pytest.mark.asyncio
async def test_inline_requires_ai_session_id(
client: AsyncClient, auth_headers
):
"""origin='pilot_inline' without ai_session_id is rejected."""
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline"},
headers=auth_headers,
)
assert r.status_code == 400
assert "ai_session_id" in r.text.lower()
@pytest.mark.asyncio
async def test_inline_ai_session_must_belong_to_caller(
client: AsyncClient, test_user, auth_headers, test_db
):
"""ai_session_id pointing at another user's session is rejected."""
# Create pilot session owned by a DIFFERENT user
from app.models.user import User
from app.models.account import Account
other_account = Account(id=uuid4(), name="other", display_code="OTH-0001")
test_db.add(other_account)
await test_db.flush()
other_user = User(
id=uuid4(), email="other@example.com",
password_hash="x", name="Other", role="engineer",
is_super_admin=False, is_team_admin=False, is_active=True,
is_service_account=False, must_change_password=False,
account_id=other_account.id, account_role="engineer",
)
test_db.add(other_user)
await test_db.flush()
# Build user dict in the same shape as the test_user fixture
other_user_dict = {
"user_data": {"id": str(other_user.id), "account_id": str(other_account.id)}
}
other_session_id = await _make_pilot_session(test_db, other_user_dict)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": other_session_id},
headers=auth_headers,
)
assert r.status_code in (403, 404), r.text
@pytest.mark.asyncio
async def test_list_sessions_excludes_inline(
client: AsyncClient, test_user, auth_headers, test_db
):
"""GET /scripts/builder/sessions returns only standalone rows."""
ai_session_id = await _make_pilot_session(test_db, test_user)
# Create one inline session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
# Create one standalone session
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
r = await client.get("/api/v1/scripts/builder/sessions", headers=auth_headers)
assert r.status_code == 200
body = r.json()
# Depending on response shape, this may be a list or {"sessions": [...]}.
items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
# Response schema does not surface `origin`; len==1 is the only meaningful guard:
# inline row would push this to 2.
assert len(items) == 1
@pytest.mark.asyncio
async def test_inline_sessions_do_not_count_against_cap(
client: AsyncClient, test_user, auth_headers, test_db
):
"""Creating 5 pilot_inline sessions does not block a subsequent standalone."""
# Create 5 distinct pilot sessions and attach inline builder sessions to each
for _ in range(5):
ai_session_id = await _make_pilot_session(test_db, test_user)
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell", "origin": "pilot_inline",
"ai_session_id": ai_session_id},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text
# A standalone create should still succeed — inline sessions don't count
r = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert r.status_code in (200, 201), r.text

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,9 @@ services:
- DEBUG=true
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
- DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
# Dedicated test database — pytest will refuse to run against any DB
# whose name doesn't contain 'test' (conftest.py safety assertion).
- DATABASE_TEST_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow_test
- SECRET_KEY=${SECRET_KEY}
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=15

View File

@@ -2,8 +2,8 @@
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
> **Status:** Phases 07 implemented. Phase 7 delivered polish: fact-synthesis loading indicator in `WhatWeKnow`, "thinking" pip in the task-lane header, quiet-state hint when questions/checks/fix are all absent, keyboard shortcuts (`⌘K` palette already present, `⌘↵` send, `⌘G` toggle script panel, `?` help overlay), and responsive bottom-drawer lane on viewports <1200px with a floating "Tasks" toggle. `tsc -b` and `npm run build` both clean.
> **Last updated:** April 22, 2026 (Phase 6post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
> **Status:** Phases 09 implemented. Phase 9 shipped the tabbed Script Builder integration (chat-region tab strip, `ScriptBuilderTab` controller with AI + Monaco editor modes, `InlineNoTemplateDialog` chat-region relocation, `PATCH /script` endpoint, `origin` discriminator migration reusing the existing `ai_session_id` FK, `applied_at` semantics correction, and `EscalateInterceptDialog` fourth "partial" choice). `tsc -b` and `npm run build` both clean.
> **Last updated:** April 24, 2026 (Phase 9Tabbed Script Builder — committed; handoff and migration spec updated)
---
@@ -891,6 +891,56 @@ git commit -m "feat(pilot): add post-resolve templatize prompt for draft templat
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
```
### Phase 8 — Fix Outcome Banner
**Plan and rationale:** [phase-8-fix-outcome-banner.md](phase-8-fix-outcome-banner.md)
**Mockups:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html), [mockups/07-verify-states.html](mockups/07-verify-states.html)
**What this phase does:** Removes the `SuggestedFix` card as the primary interaction point for fix application. Replaces it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that stays visible at the bottom of the conversation column regardless of task-lane scroll depth. Addresses the user-reported discoverability problem: *"the task lane fills up pretty quick … the suggested fix … is easily missed."*
**Key backend additions:**
- Six new columns on `session_suggested_fixes`: `status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome` endpoint to record the engineer's decision
- `[FIX_OUTCOME]` marker in the FlowPilot system prompt, parsed by `unified_chat_service.py` to trigger the banner
**Key frontend additions:**
- `ProposalBanner` component (`frontend/src/components/pilot/ProposalBanner.tsx`) — slide-up banner anchored above the chat composer; shows fix title, confidence, and Accept / Dismiss / Escalate actions; auto-collapses after session resolves
- `EscalateInterceptDialog` — intercepts the Escalate action when a fix proposal is active, asking whether the engineer wants to note that the fix was attempted before escalating
**Commit range:** `cdd8bb0` (Phase 8 Task 1 start) through `8582d24`
```
git commit -m "feat(pilot): Phase 8 — fix outcome banner replaces task-lane SuggestedFix CTA"
```
### Phase 9 — Tabbed Script Builder
**Spec:** [phase-9-script-builder-tab.md](phase-9-script-builder-tab.md)
**Implementation plan:** [phase-9-implementation-plan.md](phase-9-implementation-plan.md)
**What this phase does:** Resolves open items #1 (NoTemplateDialog narrow-lane bug) and #3 (Tabbed Script Builder) from the Phase 6/7 backlog. The chat region gains a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip` + a new `ScriptBuilderTab` controller) that hosts two modes: an AI path reusing the existing (untouched) `ScriptBuilderChat`, and a "Write it myself" path using `ScriptBodyEditor` (Monaco). Engineer submit writes the drafted script back to `session_suggested_fixes.ai_drafted_script` via a new PATCH endpoint — `applied_at` is NOT stamped (a draft is not an application). Tabs use `display: none` toggling so chat scroll position, draft message, AI history, and Monaco buffer are all preserved across switches. `InlineNoTemplateDialog` is relocated from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper, eliminating the narrow-lane viewport-breakpoint collision that made the three-option grid unusable.
**Key backend additions:**
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script` — writes `ai_drafted_script` + `ai_drafted_parameters` without stamping `applied_at`; bumps `state_version` so Resolve/Escalate preview bundles regenerate; 409 on terminal fix status
- Alembic migration adds `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` to `script_builder_sessions` (CHECK enum `'standalone'|'pilot_inline'` + invariant `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL`); reuses the pre-existing `ai_session_id` FK rather than adding a new parent column; partial unique index `ux_script_builder_sessions_pilot_inline` on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency
- `POST /api/v1/scripts/builder/sessions` extended: accepts `origin` + `ai_session_id` with auth (pilot session must belong to caller); returns existing row on duplicate; race-safe via `IntegrityError` + re-read fallback; `list_sessions` and `count_user_sessions` default-scope to `origin='standalone'` so inline sessions don't pollute the dashboard or count against the 5-session cap
- `applied_at` semantics corrected: stamps only on run-declaring actions — `TemplateMatchPanel` "I ran this" click via new `onMarkRun` prop, and `NoTemplateDialog` decisions `one_off`/`draft_template` (both labelled "Run now, …"). `build_template` does NOT stamp. Script Builder tab Submit does NOT stamp. Banner `Apply` click no longer stamps directly
**Key frontend additions:**
- `ChatTabStrip` — `[Chat] [Script Builder ●]` header strip in the chat region when the active fix needs a drafted script (status proposed/applied_partial, no template, no drafted script)
- `ScriptBuilderTab` — new controller wrapping `ScriptBuilderChat` (AI mode) + `ScriptBodyEditor` (Monaco, "Write it myself" mode); get-or-create on mount; Submit calls `sessionSuggestedFixesApi.patchScript`
- `InlineNoTemplateDialog` — chat-region slide-up wrapper around the existing `NoTemplateDialog`; replaces the previous task-lane `bottomSlot` rendering of the drafted-script three-card decision
- `TemplateMatchPanel` gains `onMarkRun` optional prop + "✓ I ran this" primary button
- `EscalateInterceptDialog` gains a fourth "I applied some of it — partial" choice (dispatches `applied_partial` via the existing `FixOutcome` pass-through)
**Commit range:** `5bcb7aa` (Phase 9 Task 1 start) through `faf1d8d`
```
git commit -m "feat(pilot): Phase 9 — tabbed Script Builder + InlineNoTemplateDialog relocation"
```
---
## 10. Design system reference

View File

@@ -0,0 +1,165 @@
# Phase 8 Review Issues
Date: 2026-04-23
Scope reviewed:
- `backend/app/api/endpoints/session_suggested_fixes.py`
- `backend/app/services/unified_chat_service.py`
- `frontend/src/pages/AssistantChatPage.tsx`
- `frontend/src/components/pilot/ProposalBanner.tsx`
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
## 1. Outcome writes do not invalidate Resolve/Escalate preview cache
Severity: High
`PATCH /suggested-fixes/{fix_id}/outcome` updates the fix row but does not bump
`ai_sessions.state_version`. Even after adding that bump, the preview input
bundle also needs to include the fix outcome fields; otherwise a regenerated
preview still cannot distinguish proposed, partially applied, failed, or
successful fixes.
Relevant files:
- `backend/app/api/endpoints/session_suggested_fixes.py:226`
- `backend/app/api/endpoints/session_suggested_fixes.py:146`
- `backend/app/services/resolution_note_generator.py:13`
- `backend/app/services/escalation_package_generator.py:14`
Why this matters:
- Resolve and Escalate previews are cached by `(session_id, state_version)`.
- The decision endpoint already bumps `state_version`.
- The new outcome endpoint does not.
- A user can record `applied_success` / `applied_failed` / `applied_partial`
and still see markdown generated from the pre-outcome session state.
- The preview generators currently pass only the active fix title,
confidence, description, and user decision into the LLM bundle. They do not
pass `status`, `applied_at`, `verified_at`, `partial_notes`, or
`failure_reason`.
- Therefore a cache miss alone is not enough: the generated markdown may still
describe the fix as merely proposed because the outcome is absent from the
prompt input.
Recommended fix:
- Bump `AISession.state_version` inside the outcome endpoint transaction.
- Include suggested-fix outcome state in both preview bundles:
- `status`
- `applied_at`
- `verified_at`
- `partial_notes`
- `failure_reason`
- Update the resolution-note prompt expectations so `applied_success` produces
closure language, `applied_failed` states that the proposed fix did not
resolve the issue, and `applied_partial` includes the engineer's partial
notes.
- Update the escalation-package prompt expectations so failed/partial outcomes
appear under "What we've tried" and inform "Suggested next steps."
- Add a test proving a preview generated before an outcome change is
invalidated after the outcome patch and that the regenerated preview input
includes the recorded outcome.
## 2. "Apply" is not persisted, so Verifying state is lost on reload/reselect
Severity: High
Phase 8 introduces a Verifying lifecycle in the UI, but clicking Apply only
sets local React state.
Relevant files:
- `frontend/src/pages/AssistantChatPage.tsx:142`
- `frontend/src/pages/AssistantChatPage.tsx:516`
- `backend/app/api/endpoints/session_suggested_fixes.py:276`
Why this matters:
- `bannerApplied` is a client-side-only flag.
- `handleApplyFix()` opens the script panel and flips local state, but does not
persist anything.
- `applied_at` is only stamped later when an outcome is patched.
- After refresh, chat reselect, or multi-tab use, a fix that had entered
Verifying falls back to `proposed`.
- Nudge timing, resolve auto-success, and escalate interception therefore do
not survive normal session resume.
Recommended fix:
- Persist "apply started" as part of the fix lifecycle.
- Either add an explicit backend transition for apply/start-verifying, or
persist `applied_at` when Apply is clicked.
- Add a test or browser regression check covering refresh/reselect continuity.
## 3. Rejecting an AI outcome proposal is only local and will reappear
Severity: Medium
Rejecting the AI-confirming banner clears `ai_outcome_proposal` only in local
component state.
Relevant files:
- `frontend/src/pages/AssistantChatPage.tsx:571`
- `frontend/src/pages/AssistantChatPage.tsx:431`
Why this matters:
- `handleRejectAIProposal()` only updates local `activeFix`.
- The server-side `ai_outcome_proposal` remains unchanged.
- The proposal comes back on the next `refreshSessionDerived()` call, which
happens after sends, task submissions, and chat selection.
- "Not yet" is therefore a temporary hide, not a real rejection/correction.
Recommended fix:
- Add a backend way to clear or reject `ai_outcome_proposal`.
- Make the reject action persist so the banner does not immediately re-arm on
the next refetch.
## 4. Pre-existing failing decision test
Severity: Low (test gap, no runtime regression)
`tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
was authored in Phase 3 (`66e5920`) when the `decision` endpoint had no
validation on `ai_drafted_script`. Phase 5 (`fa61376`) added a 400 guard:
when the decision is `one_off`, `draft_template`, or `build_template` and the
fix has no `ai_drafted_script` (and the caller provides no `edited_script` in
the request body), the endpoint returns 400 with the message "Suggested fix has
no ai_drafted_script — use /api/v1/scripts/generate for template-matched
fixes."
The test creates a fix without an `ai_drafted_script` and posts
`{"decision": "draft_template"}` naked, so the guard fires and returns 400. The
test still asserts 200. This was already broken before Phase 8 began — commit
`cdd8bb0` (first Phase 8 commit) is 8 commits after `fa61376`.
Root cause: test was never updated to match the Phase 5 contract change.
Recommended fix for the next branch:
- Option A (minimal): supply `ai_drafted_script="echo hello"` when creating the
fix fixture, or add `edited_script` to the POST body. Validates the happy path
for `draft_template` with a real drafted body.
- Option B (comprehensive): add a separate test case asserting the 400 when
`ai_drafted_script` is null and no `edited_script` is provided, then fix the
existing test as in Option A. The 400-guard already has coverage in the
Phase 5 test file; the main gap is just the missing fixture update here.
No Phase 8 code change required — this is a test-fixture gap from Phase 3/5
drift, not a regression introduced in this branch.
## Test Context
Relevant backend suites were run serially from `backend/`:
```bash
pytest tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py tests/test_session_suggested_fixes_api.py -q
```
Observed result:
- `28 passed`
- `1 failed`
Remaining failure:
- `tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
Notes:
- That failing test is in the older decision-path suite and expects
`draft_template` to succeed without a drafted script.
- The new outcome endpoint tests and marker parser tests passed in the serial
run.
- The three issues above are based on code inspection and remain valid
regardless of that separate failing test.
- Full root cause analysis documented in section 4 above.

View File

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

View File

@@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Suggested Fix → Resolve CTA merge (Option A)</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-border: rgba(251, 191, 36, 0.28);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-border: rgba(52, 211, 153, 0.28);
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
margin-bottom: 28px;
}
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 840px;
}
.columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* ----- Column scaffold (pretending to be the task-lane rail) ----- */
.col {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 12px;
display: flex;
flex-direction: column;
height: 760px;
overflow: hidden;
}
.col-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-sidebar);
}
.col-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--text-heading);
letter-spacing: 0.01em;
}
.col-head-tag {
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted);
}
.col-head-tag.today { color: var(--text-muted-foreground); }
.col-head-tag.opt-a { color: var(--accent); }
.col-head-tag.opt-a-disabled { color: var(--warning); }
.lane-body {
flex: 1;
overflow-y: auto;
padding: 14px 14px 10px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ----- Section labels (match current component styling) ----- */
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.dot-accent { background: var(--accent); }
.dot-warning { background: var(--warning); }
.dot-success { background: var(--success); }
.section-meta {
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.conf-high { color: var(--success); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
/* ----- What-we-know facts ----- */
.fact {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 12px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.fact + .fact { margin-top: 8px; }
.fact-icon {
width: 14px;
height: 14px;
border-radius: 3px;
background: var(--accent-dim);
border: 1px solid var(--accent-border);
flex-shrink: 0;
margin-top: 2px;
}
.fact-body { min-width: 0; flex: 1; }
.fact-title {
font-size: 12.5px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fact-meta {
margin-top: 3px;
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* ----- Suggested fix card (today only) ----- */
.fix-card {
border-radius: 8px;
border: 1px solid var(--warning-border);
border-left: 3px solid var(--warning);
background: var(--warning-dim);
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.fix-spark {
color: var(--warning);
flex-shrink: 0;
margin-top: 1px;
}
.fix-title {
font-size: 13px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fix-desc {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted-foreground);
line-height: 1.5;
}
.fix-hint {
margin-top: 6px;
font-size: 11px;
color: var(--success);
}
.fix-x {
margin-left: auto;
color: var(--text-muted);
background: transparent;
border: 0;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
}
/* ----- Action bar at bottom ----- */
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
gap: 8px;
}
.action-row {
display: flex;
gap: 8px;
}
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 10px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s ease, background-color 0.12s ease, color 0.12s ease;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-secondary {
flex: 0 0 auto;
min-width: 96px;
}
.btn-resolve-today {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
font-weight: 600;
}
.btn-resolve-today:hover { background: #7ab4fb; color: #0a0d14; }
/* Option A — Resolve w/ embedded fix */
.btn-resolve-merged {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-start;
min-height: 52px;
text-align: left;
}
.btn-resolve-merged:hover { background: #7ab4fb; color: #0a0d14; }
.btn-resolve-merged .rc-leading {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: rgba(10, 13, 20, 0.72);
font-family: 'Bricolage Grotesque', sans-serif;
}
.btn-resolve-merged .rc-title {
font-size: 13.5px;
font-weight: 600;
color: #0a0d14;
line-height: 1.25;
letter-spacing: -0.01em;
}
.btn-resolve-merged .rc-body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.btn-resolve-merged .rc-conf {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(10, 13, 20, 0.14);
color: #0a0d14;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.btn-resolve-merged .rc-chevron {
color: rgba(10, 13, 20, 0.55);
flex-shrink: 0;
}
/* Disabled (no proposal yet) */
.btn-resolve-disabled {
flex: 1;
background: var(--bg-card);
color: var(--text-muted-foreground);
border: 1px dashed var(--border-hover);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-start;
min-height: 52px;
cursor: not-allowed;
text-align: left;
}
.btn-resolve-disabled .rc-leading {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-muted);
font-family: 'Bricolage Grotesque', sans-serif;
}
.btn-resolve-disabled .rc-title {
font-size: 13px;
font-weight: 500;
color: var(--text-muted-foreground);
line-height: 1.25;
}
/* Escalate / overflow */
.btn-escalate {
background: transparent;
color: var(--text-muted-foreground);
}
.btn-escalate:hover { color: var(--text-primary); }
/* tiny spinner dot for the waiting state */
.pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--warning);
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5);
animation: pulse 1.6s infinite;
flex-shrink: 0;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
70% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
}
/* Annotation callouts beneath the columns */
.callout {
margin-top: 14px;
padding: 12px 14px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
font-size: 12px;
color: var(--text-muted-foreground);
line-height: 1.55;
}
.callout strong { color: var(--text-heading); font-weight: 600; }
.callout.note-accent { border-left: 3px solid var(--accent); }
.callout.note-warning { border-left: 3px solid var(--warning); }
.callout.note-muted { border-left: 3px solid var(--border-hover); }
.legend {
margin-top: 40px;
padding: 18px 20px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 32px;
font-size: 12.5px;
color: var(--text-muted-foreground);
line-height: 1.55;
}
.legend h4 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--text-heading);
margin-bottom: 6px;
letter-spacing: 0;
}
.legend li { margin-top: 4px; }
/* subtle faux scrollbar hint */
.lane-body::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Option A — Suggested Fix merges into the Resolve CTA</div>
<div class="page-sub">
Three versions of the same task lane. <strong style="color:var(--text-primary)">Today</strong> keeps Suggested Fix as a separate card that gets pushed down by a long facts list. <strong style="color:var(--text-primary)">Option A (armed)</strong> deletes the card — the Resolve button at the bottom becomes the proposal. <strong style="color:var(--text-primary)">Option A (waiting)</strong> is what the same bar looks like before the AI emits a proposal.
</div>
</div>
<div class="columns">
<!-- ============== COLUMN 1: TODAY ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Today</div>
<div class="col-head-tag today">Baseline</div>
</div>
<div class="lane-body">
<!-- What we know -->
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
<div class="fact-meta">promoted 14:18 · from chat</div>
</div>
</div>
</section>
<!-- Suggested Fix card (this is the thing that gets buried) -->
<section>
<div class="section-label">
<span class="dot dot-warning"></span>
Suggested fix
<span class="section-meta">·</span>
<span class="conf-high">94% confidence</span>
</div>
<div class="fix-card">
<svg class="fix-spark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<div style="min-width:0;flex:1">
<div class="fix-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="fix-desc">Remove stale entries from Credential Manager referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.</div>
<div class="fix-hint">✓ Matches an existing Script Library template — click to use</div>
</div>
<button class="fix-x" aria-label="Dismiss"></button>
</div>
</section>
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-today">Resolve</button>
</div>
</div>
</div>
<div class="callout note-muted">
<strong>Baseline problem.</strong> The Suggested Fix card sits after What-we-know. With 5+ facts (common by mid-session) it's below the fold. The generic <em>Resolve</em> button at the bottom doesn't surface what would be resolved, so the engineer has to scroll up, read the card, then scroll back down to act.
</div>
</div>
<!-- ============== COLUMN 2: OPTION A — ARMED ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Option A — armed</div>
<div class="col-head-tag opt-a">Proposal ready</div>
</div>
<div class="lane-body">
<!-- Same facts, but no Suggested Fix card -->
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
<div class="fact-meta">promoted 14:18 · from chat</div>
</div>
</div>
</section>
<!-- NO Suggested Fix card here — it lives on the button -->
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-merged" aria-label="Resolve with: Clear cached credentials + rebuild Outlook profile (94% confidence)">
<div class="rc-body">
<div class="rc-leading">Resolve with</div>
<div class="rc-title">Clear cached credentials + rebuild Outlook profile</div>
</div>
<span class="rc-conf">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
94%
</span>
<svg class="rc-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</div>
<div class="callout note-accent">
<strong>What changes.</strong> The Suggested Fix card is gone. Its content moved onto the Resolve button, which is always in view. One click = accept the fix + open the existing <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">ResolutionNotePreview</code> popover pre-filled. No card-then-button two-step.
</div>
</div>
<!-- ============== COLUMN 3: OPTION A — WAITING ============== -->
<div>
<div class="col">
<div class="col-head">
<div class="col-head-label">Option A — waiting</div>
<div class="col-head-tag opt-a-disabled">No proposal yet</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 2 facts</span>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
</div>
<div class="fact">
<span class="fact-icon"></span>
<div class="fact-body">
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
</div>
</section>
</div>
<div class="action-bar">
<div class="action-row">
<button class="btn btn-escalate btn-secondary">Escalate</button>
<button class="btn btn-resolve-disabled" disabled aria-label="Resolve (waiting for AI proposal)">
<span class="pulse" aria-hidden="true"></span>
<div class="rc-body">
<div class="rc-leading">Resolve</div>
<div class="rc-title">Waiting for proposal…</div>
</div>
</button>
</div>
</div>
</div>
<div class="callout note-warning">
<strong>Before confidence threshold.</strong> Same slot, disabled state. Amber pulse signals the AI is still reasoning. Below threshold or no proposal yet → same visual — the engineer can still use <em>Escalate</em> at any time.
</div>
</div>
</div>
<!-- ============== LEGEND / TRADE-OFFS ============== -->
<div class="legend">
<div>
<h4>Why this helps discoverability</h4>
<ul style="padding-left:18px;list-style:disc">
<li>Proposal is in the place the engineer looks to <em>act</em>, not in the scrolling lane above.</li>
<li>Resolve bar is already sticky at the bottom — no new sticky patterns needed (preserves the <code style="font-family:'JetBrains Mono',monospace;font-size:11px">8879f96</code> fix).</li>
<li>Accepting a fix and resolving the session collapse into one click instead of two.</li>
</ul>
</div>
<div>
<h4>What you give up</h4>
<ul style="padding-left:18px;list-style:disc">
<li>No space for secondary info on the button (reasoning, alternative fixes). Would need an expand/chevron or hover tooltip.</li>
<li>No standalone "dismiss this fix" affordance — need to decide where dismiss/reject lives (chevron menu? secondary button?).</li>
<li>If the AI proposes multiple candidates, only the top one fits the button. Need a "▾ 2 other candidates" menu.</li>
</ul>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,849 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Suggested Fix as slide-up composer banner</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-dim-strong: rgba(251, 191, 36, 0.16);
--warning-border: rgba(251, 191, 36, 0.32);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-border: rgba(52, 211, 153, 0.28);
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 72px;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 980px;
line-height: 1.55;
}
/* =================== Main frame =================== */
.frame {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 380px;
height: 780px;
}
/* ------ Chat area ------ */
.chat {
display: flex;
flex-direction: column;
background: var(--bg-page);
min-width: 0;
}
.chat-head {
padding: 14px 20px;
border-bottom: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.chat-head-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--text-heading);
}
.chat-head-sub {
font-size: 11.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.chat-head-actions {
display: flex;
gap: 8px;
}
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 28px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg {
max-width: 640px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.msg.user { align-self: flex-end; }
.msg-av {
width: 26px; height: 26px;
border-radius: 50%;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
display: flex; align-items: center; justify-content: center;
margin-top: 2px;
}
.msg.user .msg-av {
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--accent-border);
}
.msg.ai .msg-av {
background: var(--warning-dim);
color: var(--warning);
border: 1px solid var(--warning-border);
}
.msg-body {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 10px 13px;
font-size: 13px;
color: var(--text-primary);
line-height: 1.55;
}
.msg.user .msg-body {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--text-heading);
}
.msg-meta {
margin-top: 4px;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
/* ------ Composer area (sticky bottom of chat) ------ */
.composer-wrap {
border-top: 1px solid var(--border-default);
background: var(--bg-page);
position: relative;
}
/* ------ Slide-up banner ------ */
.proposal-banner {
margin: 0;
border-top: 1px solid var(--warning-border);
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
padding: 12px 20px 14px;
display: flex;
gap: 14px;
align-items: flex-start;
position: relative;
animation: slideUp 320ms cubic-bezier(.22, .9, .28, 1) both;
}
.proposal-banner::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--warning);
}
@keyframes slideUp {
from { transform: translateY(14px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.proposal-icon {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--warning-dim-strong);
border: 1px solid var(--warning-border);
display: flex; align-items: center; justify-content: center;
color: var(--warning);
flex-shrink: 0;
margin-top: 2px;
}
.proposal-body {
flex: 1;
min-width: 0;
}
.proposal-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--warning);
font-family: 'Bricolage Grotesque', sans-serif;
}
.proposal-head .pill {
padding: 2px 7px;
border-radius: 999px;
background: rgba(251, 191, 36, 0.20);
color: var(--warning);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.5px;
font-family: 'IBM Plex Sans', sans-serif;
font-variant-numeric: tabular-nums;
}
.proposal-title {
margin-top: 3px;
font-size: 14px;
font-weight: 600;
color: var(--text-heading);
line-height: 1.35;
letter-spacing: -0.005em;
}
.proposal-desc {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted-foreground);
line-height: 1.5;
}
.proposal-hint {
margin-top: 6px;
font-size: 11.5px;
color: var(--success);
display: inline-flex;
align-items: center;
gap: 5px;
}
.proposal-actions {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
padding-top: 2px;
}
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 12.5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
white-space: nowrap;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-apply {
background: var(--warning);
color: #1a1200;
border-color: transparent;
font-weight: 600;
padding: 9px 14px;
}
.btn-apply:hover { background: #ffce4f; color: #1a1200; }
.btn-ghost {
background: transparent;
color: var(--text-muted-foreground);
border-color: transparent;
padding: 8px 10px;
}
.btn-ghost:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
border-color: transparent;
}
.icon-btn {
width: 30px; height: 30px;
padding: 0;
background: transparent;
color: var(--text-muted-foreground);
border: 1px solid transparent;
}
.icon-btn:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
}
/* ------ Composer ------ */
.composer {
padding: 14px 20px 16px;
display: flex;
align-items: flex-end;
gap: 10px;
}
.composer-input {
flex: 1;
min-height: 44px;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 10px 14px;
color: var(--text-muted-foreground);
font-size: 13px;
line-height: 1.4;
display: flex;
align-items: center;
}
.composer-send {
width: 44px; height: 44px;
border-radius: 10px;
background: var(--accent);
color: #0a0d14;
border: 0;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
/* ------ Task lane (right rail) ------ */
.lane {
border-left: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex;
flex-direction: column;
min-height: 0;
}
.lane-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
}
.lane-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--text-heading);
}
.lane-body {
flex: 1;
overflow-y: auto;
padding: 14px 14px 10px;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-accent { background: var(--accent); }
.dot-muted { background: var(--text-muted); }
.section-meta {
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.fact {
background: var(--bg-card);
border: 1px solid var(--border-default);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 10px 12px;
}
.fact + .fact { margin-top: 8px; }
.fact-title {
font-size: 12.5px;
font-weight: 500;
color: var(--text-heading);
line-height: 1.4;
}
.fact-meta {
margin-top: 3px;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.dismissed-pill {
padding: 8px 10px;
background: var(--bg-card);
border: 1px dashed var(--border-hover);
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--text-muted-foreground);
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
}
.dismissed-pill:hover { border-color: var(--warning-border); color: var(--warning); }
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
display: flex;
gap: 8px;
}
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
.btn-resolve {
flex: 1;
background: var(--accent);
color: #0a0d14;
border-color: transparent;
font-weight: 600;
padding: 10px 12px;
}
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
/* =================== Callouts =================== */
.callout {
margin-top: 20px;
padding: 14px 16px;
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
font-size: 13px;
color: var(--text-muted-foreground);
line-height: 1.55;
border-left: 3px solid var(--warning);
}
.callout strong { color: var(--text-heading); font-weight: 600; }
/* =================== State detail row =================== */
.states-title {
margin-top: 48px;
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 18px;
color: var(--text-heading);
}
.states-sub {
margin-top: 4px;
color: var(--text-muted-foreground);
font-size: 13px;
}
.states {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.state {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.state-label {
padding: 10px 14px;
border-bottom: 1px solid var(--border-default);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 12.5px;
color: var(--text-heading);
background: var(--bg-sidebar);
}
.state-body {
padding: 0;
background: var(--bg-page);
min-height: 220px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.state-mini-chat {
flex: 1;
padding: 14px;
opacity: 0.55;
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: flex-end;
font-family: 'JetBrains Mono', monospace;
}
/* Collapsed banner variant */
.banner-collapsed {
border-top: 1px solid var(--warning-border);
background: var(--warning-dim);
padding: 8px 14px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-primary);
position: relative;
}
.banner-collapsed::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--warning);
}
.banner-collapsed-title {
font-weight: 500;
color: var(--text-heading);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.banner-collapsed .pill {
padding: 1px 7px;
border-radius: 999px;
background: rgba(251, 191, 36, 0.20);
color: var(--warning);
font-size: 10.5px;
font-weight: 700;
}
.banner-collapsed .expand {
color: var(--text-muted-foreground);
font-size: 11px;
}
/* mini composer for the detail states */
.mini-composer {
border-top: 1px solid var(--border-default);
padding: 10px 14px;
display: flex;
gap: 8px;
align-items: center;
}
.mini-input {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-default);
border-radius: 8px;
padding: 7px 10px;
font-size: 11.5px;
color: var(--text-muted);
}
.mini-send {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--accent);
color: #0a0d14;
border: 0;
font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
/* pill in chat stream (replaced state) */
.replaced-note {
align-self: flex-end;
font-size: 10.5px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
padding: 4px 8px;
background: var(--bg-card);
border: 1px dashed var(--border-hover);
border-radius: 6px;
}
/* annotation captions under each state */
.state-caption {
padding: 10px 14px 12px;
font-size: 11.5px;
color: var(--text-muted-foreground);
line-height: 1.5;
border-top: 1px solid var(--border-default);
background: var(--bg-sidebar);
}
.state-caption strong { color: var(--text-heading); font-weight: 600; }
.lane-body::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
.chat-scroll::-webkit-scrollbar { width: 6px; }
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Option C — Suggested Fix slides up from the chat composer</div>
<div class="page-sub">
The AI's proposal docks as a persistent banner just above the chat composer — right where the engineer's eyes already are. Apply lives on the banner (warning amber). <em>Resolve</em> stays generic at the bottom of the task lane, so there's no false-resolve risk. The Suggested Fix card is removed from the task lane entirely.
</div>
</div>
<!-- ============ MAIN: Armed + expanded ============ -->
<div class="frame">
<div class="chat">
<div class="chat-head">
<div>
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
<div class="chat-head-sub">ticket #48213 · in progress · 14:22</div>
</div>
<div class="chat-head-actions">
<button class="btn btn-ghost">Share update</button>
<button class="btn icon-btn" aria-label="More">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
</div>
</div>
<div class="chat-scroll">
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">
A few things we know line up with a stale-credential pattern rather than an MFA or network issue. Can you confirm whether the user has other Microsoft 365 services (Teams, SharePoint) working on the same workstation?
</div>
<div class="msg-meta">14:16</div>
</div>
</div>
<div class="msg user">
<div>
<div class="msg-body">Teams works fine. SharePoint in browser is fine too. It's just Outlook.</div>
<div class="msg-meta">14:17</div>
</div>
<div class="msg-av">ME</div>
</div>
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">
That narrows it to the Outlook profile specifically. Given Credential Manager still has entries pointing at the prior tenant, the cleanest path is to clear those entries and rebuild the local Outlook profile — the client will re-auth against the current tenant from scratch.
</div>
<div class="msg-meta">14:22</div>
</div>
</div>
</div>
<!-- ============ Slide-up banner ============ -->
<div class="composer-wrap">
<div class="proposal-banner" role="region" aria-label="AI proposed fix">
<div class="proposal-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="proposal-body">
<div class="proposal-head">
<span>Suggested Fix</span>
<span class="pill">94% confidence</span>
</div>
<div class="proposal-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="proposal-desc">
Remove stale Credential Manager entries referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.
</div>
<div class="proposal-hint">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Matches an existing Script Library template — one-click apply
</div>
</div>
<div class="proposal-actions">
<button class="btn btn-ghost" aria-label="Collapse banner">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="btn btn-ghost" aria-label="Dismiss fix">Dismiss</button>
<button class="btn btn-apply">
Apply fix
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<div class="composer">
<div class="composer-input">Ask a follow-up, paste an error, drop a screenshot…</div>
<button class="composer-send" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<!-- ============ Task lane (no Suggested Fix card) ============ -->
<div class="lane">
<div class="lane-head">
<div class="lane-head-label">Task lane</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
<div class="fact">
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">MFA prompt appears but fails silently — no authenticator notification.</div>
<div class="fact-meta">promoted 14:11 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Other devices under same account authenticate successfully.</div>
<div class="fact-meta">promoted 14:14 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
<div class="fact-meta">promoted 14:22 · from chat</div>
</div>
</section>
</div>
<div class="action-bar">
<button class="btn btn-escalate">Escalate</button>
<button class="btn btn-resolve">Resolve</button>
</div>
</div>
</div>
<div class="callout">
<strong>How it reads.</strong> Proposal arrives with a 320ms slide-up from below the composer, docks as a persistent banner until applied, dismissed, or replaced. Apply is amber (not accent-blue) so it visually belongs to the proposal, not the chat send button. Resolve in the task lane stays generic — there's no false-resolve risk because the two actions are spatially and visually separate.
</div>
<!-- ============ State detail row ============ -->
<div class="states-title">Banner states</div>
<div class="states-sub">What the same region looks like in the other three states — collapsed to save chat space, after the engineer dismisses it, and when a new proposal replaces an existing one.</div>
<div class="states">
<!-- STATE 1: Collapsed -->
<div class="state">
<div class="state-label">Collapsed (saves chat space)</div>
<div class="state-body">
<div class="state-mini-chat">…earlier messages…</div>
<div class="banner-collapsed">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<span class="banner-collapsed-title">Clear cached credentials + rebuild Outlook profile</span>
<span class="pill">94%</span>
<span class="expand">▸ expand</span>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>~28px strip.</strong> Auto-collapses after 30s of no interaction, or when the engineer clicks the chevron. Title + confidence still visible. Click strip → expands. Apply still reachable via the expanded state.
</div>
</div>
<!-- STATE 2: Dismissed (pill in lane) -->
<div class="state">
<div class="state-label">Dismissed — parked in the task lane</div>
<div class="state-body">
<div class="state-mini-chat">chat unobstructed · banner gone</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div style="padding: 12px 14px; background: var(--bg-sidebar); border-top: 1px solid var(--border-default);">
<div class="section-label" style="padding-bottom: 6px">
<span class="dot dot-muted"></span>
Dismissed proposals
<span class="section-meta">· 1</span>
</div>
<div class="dismissed-pill">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
<span style="flex:1;color:var(--text-heading)">Clear cached credentials…</span>
<span style="color:var(--text-muted)">restore ↺</span>
</div>
</div>
<div class="state-caption">
<strong>Recoverable, out of the way.</strong> Dismissing the banner parks the proposal as a pill in the task lane. Clicking restore → banner slides back in. Prevents accidental loss.
</div>
</div>
<!-- STATE 3: Replaced -->
<div class="state">
<div class="state-label">Replaced — new proposal overrides old</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;justify-content:flex-end;">
<span class="replaced-note">previous: "Rebuild Outlook profile" — didn't resolve, new proposal below</span>
</div>
<div class="proposal-banner" style="padding:10px 14px;gap:10px;">
<div class="proposal-icon" style="width:22px;height:22px;border-radius:6px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="proposal-body">
<div class="proposal-head" style="font-size:9px">
<span>New suggested fix</span>
<span class="pill" style="font-size:9.5px;padding:1px 6px">78%</span>
</div>
<div class="proposal-title" style="font-size:12.5px">Reset Autodiscover registry entries for this user</div>
</div>
<button class="btn btn-apply" style="padding:6px 10px;font-size:11.5px">Apply</button>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Old proposal cross-fades out, new one slides in.</strong> 200ms cross-fade, same slot. A tiny footnote in chat ("previous didn't resolve") preserves the audit trail without re-stacking banners.
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,805 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FlowPilot — Post-apply outcome states</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-sidebar: #0e1016;
--bg-page: #16181f;
--bg-card: #1e2028;
--bg-elevated: #2a2d38;
--border-default: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.22);
--text-heading: #f1f5f9;
--text-primary: #e2e8f0;
--text-muted-foreground: #94a3b8;
--text-muted: #64748b;
--accent: #60a5fa;
--accent-dim: rgba(96, 165, 250, 0.10);
--accent-dim-strong: rgba(96, 165, 250, 0.16);
--accent-border: rgba(96, 165, 250, 0.30);
--warning: #fbbf24;
--warning-dim: rgba(251, 191, 36, 0.10);
--warning-dim-strong: rgba(251, 191, 36, 0.16);
--warning-border: rgba(251, 191, 36, 0.32);
--success: #34d399;
--success-dim: rgba(52, 211, 153, 0.10);
--success-dim-strong: rgba(52, 211, 153, 0.16);
--success-border: rgba(52, 211, 153, 0.30);
--info: #67e8f9;
--info-dim: rgba(103, 232, 249, 0.10);
--info-dim-strong: rgba(103, 232, 249, 0.16);
--info-border: rgba(103, 232, 249, 0.30);
--danger: #f87171;
--danger-dim: rgba(248, 113, 113, 0.10);
--danger-dim-strong: rgba(248, 113, 113, 0.16);
--danger-border: rgba(248, 113, 113, 0.30);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg-sidebar);
color: var(--text-primary);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 1680px;
margin: 0 auto;
padding: 32px 24px 72px;
}
.page-header { margin-bottom: 24px; }
.page-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.page-sub {
margin-top: 6px;
color: var(--text-muted-foreground);
font-size: 13px;
max-width: 1020px;
line-height: 1.55;
}
/* ====== Shared button styles ====== */
.btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--bg-card);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-family: inherit;
font-weight: 500;
font-size: 12.5px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
white-space: nowrap;
}
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted-foreground);
padding: 8px 10px;
}
.btn-ghost:hover {
background: rgba(148, 163, 184, 0.08);
color: var(--text-primary);
border-color: transparent;
}
.icon-btn {
width: 30px; height: 30px; padding: 0;
background: transparent; border: 1px solid transparent;
color: var(--text-muted-foreground);
}
.icon-btn:hover { background: rgba(148, 163, 184, 0.08); color: var(--text-primary); }
.btn-success {
background: var(--success); color: #0a1a12; border-color: transparent; font-weight: 600;
}
.btn-success:hover { background: #55e0af; color: #0a1a12; }
.btn-danger-outline {
background: transparent; color: var(--danger); border-color: var(--danger-border);
}
.btn-danger-outline:hover { background: var(--danger-dim); color: var(--danger); border-color: var(--danger); }
.btn-danger {
background: var(--danger); color: #180808; border-color: transparent; font-weight: 600;
}
.btn-danger:hover { background: #fa8a8a; color: #180808; }
/* ====== Frame ====== */
.frame {
background: var(--bg-page);
border: 1px solid var(--border-default);
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 380px;
height: 760px;
}
.chat {
display: flex; flex-direction: column;
background: var(--bg-page);
min-width: 0;
}
.chat-head {
padding: 14px 20px;
border-bottom: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.chat-head-title {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 14px; color: var(--text-heading);
}
.chat-head-sub {
font-size: 11.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.chat-scroll {
flex: 1; overflow-y: auto;
padding: 24px 28px 16px;
display: flex; flex-direction: column; gap: 16px;
}
.msg { max-width: 640px; display: flex; gap: 10px; align-items: flex-start; }
.msg.user { align-self: flex-end; }
.msg-av {
width: 26px; height: 26px; border-radius: 50%;
flex-shrink: 0; font-size: 11px; font-weight: 600;
display: flex; align-items: center; justify-content: center; margin-top: 2px;
}
.msg.user .msg-av { background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-border); }
.msg.ai .msg-av { background: var(--warning-dim); color: var(--warning); border: 1px solid var(--warning-border); }
.msg.system .msg-av { background: rgba(148,163,184,0.08); color: var(--text-muted); border: 1px solid var(--border-default); }
.msg-body {
background: var(--bg-card); border: 1px solid var(--border-default);
border-radius: 10px; padding: 10px 13px; font-size: 13px; color: var(--text-primary);
line-height: 1.55;
}
.msg.user .msg-body { background: var(--accent-dim); border-color: var(--accent-border); color: var(--text-heading); }
.msg.system .msg-body { background: transparent; border-style: dashed; color: var(--text-muted); font-size: 12px; font-style: italic; }
.msg-meta {
margin-top: 4px; font-size: 10.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.composer-wrap { border-top: 1px solid var(--border-default); background: var(--bg-page); position: relative; }
.composer { padding: 14px 20px 16px; display: flex; align-items: flex-end; gap: 10px; }
.composer-input {
flex: 1; min-height: 44px; background: var(--bg-card);
border: 1px solid var(--border-default); border-radius: 10px;
padding: 10px 14px; color: var(--text-muted-foreground);
font-size: 13px; line-height: 1.4;
display: flex; align-items: center;
}
.composer-send {
width: 44px; height: 44px; border-radius: 10px;
background: var(--accent); color: #0a0d14; border: 0;
display: flex; align-items: center; justify-content: center; cursor: pointer;
}
/* ====== Banner generic ====== */
.banner {
position: relative;
padding: 12px 20px 14px;
display: flex; gap: 14px; align-items: flex-start;
border-top-width: 1px; border-top-style: solid;
animation: fadeIn 260ms ease-out both;
}
.banner::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0;
width: 3px;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.banner-icon {
width: 28px; height: 28px; border-radius: 7px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px;
}
.banner-body { flex: 1; min-width: 0; }
.banner-head {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; font-family: 'Bricolage Grotesque', sans-serif;
}
.banner-title {
margin-top: 3px; font-size: 14px; font-weight: 600;
color: var(--text-heading); line-height: 1.35; letter-spacing: -0.005em;
}
.banner-note {
margin-top: 3px; font-size: 12.5px; color: var(--text-muted-foreground);
line-height: 1.5;
}
.banner-actions {
display: flex; gap: 8px; align-items: center;
flex-shrink: 0; padding-top: 2px;
}
.pill {
padding: 2px 7px; border-radius: 999px;
font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
}
/* Verifying — amber pulse, mirrors the proposed color but with pulse */
.banner-verify {
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
border-top-color: var(--warning-border);
}
.banner-verify::before { background: var(--warning); }
.banner-verify .banner-icon {
background: var(--warning-dim-strong); border: 1px solid var(--warning-border); color: var(--warning);
position: relative;
}
.banner-verify .banner-icon::after {
content: ''; position: absolute; inset: -3px; border-radius: 9px;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45);
animation: pulseAmber 1.6s infinite;
}
@keyframes pulseAmber {
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
70% { box-shadow: 0 0 0 10px rgba(251, 191, 36, 0); }
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
}
.banner-verify .banner-head { color: var(--warning); }
.banner-verify .pill { background: rgba(251, 191, 36, 0.20); color: var(--warning); }
/* Partial — muted info/cyan to communicate "parked, outcome unknown" */
.banner-partial {
background: linear-gradient(180deg, var(--info-dim-strong) 0%, var(--info-dim) 100%);
border-top-color: var(--info-border);
}
.banner-partial::before { background: var(--info); }
.banner-partial .banner-icon { background: var(--info-dim-strong); border: 1px solid var(--info-border); color: var(--info); }
.banner-partial .banner-head { color: var(--info); }
.banner-partial .pill { background: rgba(103, 232, 249, 0.18); color: var(--info); }
/* AI-inferred — accent blue, AI-sourced */
.banner-ai {
background: linear-gradient(180deg, var(--accent-dim-strong) 0%, var(--accent-dim) 100%);
border-top-color: var(--accent-border);
}
.banner-ai::before { background: var(--accent); }
.banner-ai .banner-icon { background: var(--accent-dim-strong); border: 1px solid var(--accent-border); color: var(--accent); }
.banner-ai .banner-head { color: var(--accent); }
.banner-ai .pill { background: rgba(96, 165, 250, 0.20); color: var(--accent); }
/* Nudge — compact strip */
.banner-nudge {
padding: 8px 20px;
background: var(--warning-dim);
border-top-color: var(--warning-border);
align-items: center;
gap: 10px;
}
.banner-nudge::before { background: var(--warning); }
.banner-nudge .nudge-icon {
width: 16px; height: 16px; flex-shrink: 0; color: var(--warning);
}
.banner-nudge .nudge-title {
flex: 1; font-size: 12.5px; color: var(--text-primary); font-weight: 500;
}
/* ====== Task lane ====== */
.lane {
border-left: 1px solid var(--border-default);
background: var(--bg-sidebar);
display: flex; flex-direction: column; min-height: 0;
}
.lane-head {
padding: 14px 16px;
border-bottom: 1px solid var(--border-default);
display: flex; align-items: center; justify-content: space-between;
}
.lane-head-label {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 13px; color: var(--text-heading);
}
.lane-body {
flex: 1; overflow-y: auto;
padding: 14px 14px 10px;
display: flex; flex-direction: column; gap: 16px;
}
.section-label {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
text-transform: uppercase; color: var(--text-muted-foreground);
padding: 0 2px 8px;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.dot-accent { background: var(--accent); }
.dot-danger { background: var(--danger); }
.section-meta {
color: var(--text-muted); font-weight: 500; letter-spacing: 0; text-transform: none;
}
.fact {
background: var(--bg-card); border: 1px solid var(--border-default);
border-left: 3px solid var(--accent); border-radius: 8px;
padding: 10px 12px;
}
.fact + .fact { margin-top: 8px; }
.fact-title { font-size: 12.5px; font-weight: 500; color: var(--text-heading); line-height: 1.4; }
.fact-meta { margin-top: 3px; font-size: 10.5px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.failed-pill {
padding: 9px 11px; background: var(--bg-card);
border: 1px dashed var(--danger-border); border-radius: 8px;
display: flex; align-items: center; gap: 8px;
font-size: 11.5px; color: var(--text-muted-foreground);
}
.failed-pill-title { flex: 1; color: var(--text-heading); font-weight: 500; }
.failed-pill-badge {
padding: 1px 6px; border-radius: 4px; font-size: 9.5px;
font-weight: 700; letter-spacing: 0.4px;
background: var(--danger-dim); color: var(--danger);
text-transform: uppercase;
}
.action-bar {
border-top: 1px solid var(--border-default);
padding: 12px 14px 14px;
display: flex; gap: 8px;
position: relative;
}
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
.btn-resolve {
flex: 1; background: var(--accent); color: #0a0d14;
border-color: transparent; font-weight: 600; padding: 10px 12px;
}
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
/* ====== Callouts ====== */
.callout {
margin-top: 20px; padding: 14px 16px;
background: var(--bg-page); border: 1px solid var(--border-default);
border-radius: 10px; font-size: 13px; color: var(--text-muted-foreground);
line-height: 1.55; border-left: 3px solid var(--warning);
}
.callout strong { color: var(--text-heading); font-weight: 600; }
/* ====== State detail panels ====== */
.states-title {
margin-top: 48px; font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 18px; color: var(--text-heading);
}
.states-sub { margin-top: 4px; color: var(--text-muted-foreground); font-size: 13px; }
.states {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.state {
background: var(--bg-page); border: 1px solid var(--border-default);
border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column;
}
.state-label {
padding: 10px 14px; border-bottom: 1px solid var(--border-default);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 12.5px; color: var(--text-heading);
background: var(--bg-sidebar);
}
.state-body {
padding: 0; background: var(--bg-page);
min-height: 280px;
display: flex; flex-direction: column; justify-content: flex-end;
position: relative;
}
.state-mini-chat {
flex: 1; padding: 14px 16px;
font-size: 11px; color: var(--text-muted);
display: flex; align-items: flex-end; gap: 6px;
font-family: 'JetBrains Mono', monospace;
opacity: 0.6;
}
.mini-composer {
border-top: 1px solid var(--border-default);
padding: 10px 14px; display: flex; gap: 8px; align-items: center;
}
.mini-input {
flex: 1; background: var(--bg-card);
border: 1px solid var(--border-default); border-radius: 8px;
padding: 7px 10px; font-size: 11.5px; color: var(--text-muted);
}
.mini-send {
width: 28px; height: 28px; border-radius: 7px;
background: var(--accent); color: #0a0d14; border: 0; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
.state-caption {
padding: 10px 14px 12px; font-size: 11.5px;
color: var(--text-muted-foreground); line-height: 1.5;
border-top: 1px solid var(--border-default); background: var(--bg-sidebar);
}
.state-caption strong { color: var(--text-heading); font-weight: 600; }
/* ====== Escalate intercept popover ====== */
.intercept-wrap {
position: relative;
padding: 24px 14px 14px;
background: var(--bg-page);
flex: 1;
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
.intercept-popover {
position: absolute;
bottom: 70px;
left: 14px;
width: 340px;
background: var(--bg-card);
border: 1px solid var(--border-hover);
border-radius: 10px;
padding: 14px;
box-shadow: 0 18px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(96,165,250,0.15);
}
.intercept-popover::after {
content: '';
position: absolute;
bottom: -7px; left: 40px;
width: 14px; height: 14px;
background: var(--bg-card);
border-right: 1px solid var(--border-hover);
border-bottom: 1px solid var(--border-hover);
transform: rotate(45deg);
}
.intercept-head {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 600; font-size: 13px; color: var(--text-heading);
margin-bottom: 4px;
}
.intercept-sub {
font-size: 12px; color: var(--text-muted-foreground);
line-height: 1.5; margin-bottom: 12px;
}
.intercept-options {
display: flex; flex-direction: column; gap: 6px;
}
.intercept-option {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
background: var(--bg-elevated); border: 1px solid var(--border-default);
font-size: 12.5px; color: var(--text-primary);
cursor: pointer; text-align: left; width: 100%;
transition: border-color 0.12s, background-color 0.12s;
font-family: inherit;
}
.intercept-option:hover { border-color: var(--border-hover); background: var(--bg-sidebar); }
.intercept-option.primary {
border-color: var(--danger-border); background: var(--danger-dim);
}
.intercept-option.primary:hover { border-color: var(--danger); background: var(--danger-dim-strong); }
.intercept-kbd {
margin-left: auto; font-size: 10.5px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
background: rgba(148,163,184,0.08);
padding: 2px 6px; border-radius: 4px;
}
.mock-btn-row {
display: flex; gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-default);
}
.mock-escalate {
background: transparent; color: var(--text-muted-foreground);
border: 1px solid var(--border-default); padding: 9px 14px;
border-radius: 8px; font-size: 12.5px; min-width: 96px;
position: relative;
}
.mock-escalate.active {
border-color: var(--danger-border); color: var(--danger);
background: var(--danger-dim);
}
.mock-resolve {
flex: 1; background: var(--accent); color: #0a0d14;
border: 0; font-weight: 600; padding: 9px 12px;
border-radius: 8px; font-size: 12.5px;
}
/* Partial inline input row */
.partial-note {
margin-top: 4px;
padding: 6px 10px;
background: rgba(103, 232, 249, 0.08);
border: 1px solid var(--info-border);
border-radius: 6px;
font-size: 12px; color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
font-style: italic;
}
.partial-note-label {
font-style: normal; color: var(--info);
font-size: 10.5px; font-weight: 700; letter-spacing: 0.6px;
text-transform: uppercase;
}
.lane-body::-webkit-scrollbar,
.chat-scroll::-webkit-scrollbar { width: 6px; }
.lane-body::-webkit-scrollbar-thumb,
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
</style>
</head>
<body>
<div class="page">
<div class="page-header">
<div class="page-title">Post-apply outcome states — how we recognize whether a fix worked</div>
<div class="page-sub">
Hero frame shows the <strong style="color:var(--text-primary)">Verifying</strong> state — what the banner becomes the moment the engineer clicks Apply. Below, four detail panels show the other outcome paths: <strong style="color:var(--text-primary)">Partial apply</strong>, <strong style="color:var(--text-primary)">AI-inferred outcome</strong> from chat, <strong style="color:var(--text-primary)">Escalate-intercept</strong>, and the <strong style="color:var(--text-primary)">Nudge</strong> that appears when the engineer keeps chatting without confirming.
</div>
</div>
<!-- ============ HERO: VERIFYING ============ -->
<div class="frame">
<div class="chat">
<div class="chat-head">
<div>
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
<div class="chat-head-sub">ticket #48213 · in progress · 14:26</div>
</div>
</div>
<div class="chat-scroll">
<div class="msg ai">
<div class="msg-av">AI</div>
<div>
<div class="msg-body">Given Credential Manager still has entries for the prior tenant, the cleanest path is to clear those and rebuild the local Outlook profile.</div>
<div class="msg-meta">14:22</div>
</div>
</div>
<div class="msg user">
<div>
<div class="msg-body">Okay, I'll run the script now.</div>
<div class="msg-meta">14:24</div>
</div>
<div class="msg-av">ME</div>
</div>
<div class="msg system">
<div class="msg-av"></div>
<div>
<div class="msg-body">Applied fix: Clear cached credentials + rebuild Outlook profile — script completed without errors at 14:24.</div>
</div>
</div>
</div>
<!-- VERIFY BANNER (persistent after Apply) -->
<div class="composer-wrap">
<div class="banner banner-verify" role="region" aria-label="Verify fix outcome">
<div class="banner-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>Verifying</span>
<span class="pill">Applied 14:24 · 2m ago</span>
</div>
<div class="banner-title">Did "Clear cached credentials + rebuild Outlook profile" work?</div>
<div class="banner-note">Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost" aria-label="More options" title="Mark partial apply, re-open details">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
</button>
<button class="btn btn-danger-outline">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Didn't work
</button>
<button class="btn btn-success">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
It worked
</button>
</div>
</div>
<div class="composer">
<div class="composer-input">Tell the AI what happened — or click an outcome above</div>
<button class="composer-send" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<!-- Task lane: fix is now in "verifying" status — no longer a standalone suggested fix -->
<div class="lane">
<div class="lane-head">
<div class="lane-head-label">Task lane</div>
</div>
<div class="lane-body">
<section>
<div class="section-label">
<span class="dot dot-accent"></span>
What we know
<span class="section-meta">· 5 facts</span>
</div>
<div class="fact">
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
<div class="fact-meta">promoted 14:02 · from ticket</div>
</div>
<div class="fact">
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
<div class="fact-meta">promoted 14:07 · from chat</div>
</div>
<div class="fact">
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
<div class="fact-meta">promoted 14:22 · from chat</div>
</div>
</section>
</div>
<div class="action-bar">
<button class="btn btn-escalate">Escalate</button>
<button class="btn btn-resolve">Resolve</button>
</div>
</div>
</div>
<div class="callout">
<strong>How Verifying works.</strong> Clicking Apply transitions the banner into this state instead of dismissing it. No timeout — the banner stays pinned until the engineer marks <em>Worked</em>, <em>Didn't work</em>, or <em>Partial</em> (overflow). If they ignore it and keep chatting, the Nudge state (panel D below) appears after a few messages. If they hit the task lane's <em>Resolve</em> button without clicking either outcome, we auto-stamp <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">applied_success</code>. If they hit <em>Escalate</em>, panel C intercepts.
</div>
<!-- ============ DETAIL PANELS ============ -->
<div class="states-title">Outcome branches</div>
<div class="states-sub">Four paths from Verifying to a final status. Each one writes to <code style="font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--bg-card);padding:1px 6px;border-radius:3px;color:var(--text-primary)">session_suggested_fixes.status</code> so the AI's next turn has ground truth about what's been tried.</div>
<div class="states">
<!-- A. PARTIAL -->
<div class="state">
<div class="state-label">A. Partial apply — "I did some of it"</div>
<div class="state-body">
<div class="state-mini-chat">…engineer picked "Mark partial…" from the verify banner's overflow menu</div>
<div class="banner banner-partial">
<div class="banner-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>Partially applied</span>
<span class="pill">Parked</span>
</div>
<div class="banner-title">Clear cached credentials + rebuild Outlook profile</div>
<div class="partial-note">
<span class="partial-note-label">Note</span>
<span>Ran cred clear — skipped profile rebuild, user in a meeting. Back at 3:30.</span>
</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost">Edit note</button>
<button class="btn btn-danger-outline">Didn't work</button>
<button class="btn">Finish it </button>
</div>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Status:</strong> <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">applied_partial</code>, with <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">partial_notes</code> free-text. Not terminal — banner stays pinned until engineer marks a terminal outcome, or clicks <em>Finish it</em> to re-run the remainder and flip back to Verifying. AI treats partial as "tried but uncertain" — doesn't re-propose, but doesn't assume failure either.
</div>
</div>
<!-- B. AI-INFERRED CONFIRM -->
<div class="state">
<div class="state-label">B. AI-inferred outcome — from chat</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;opacity:0.8">
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:8px 12px;font-size:12px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:80%;"><strong style="font-weight:500">Engineer:</strong> "yep that fixed it, thanks"</div>
<div style="font-size:10.5px;color:var(--text-muted);padding-right:2px;">14:31 · user message triggered <code style="font-family:'JetBrains Mono',monospace;font-size:10.5px">[FIX_OUTCOME]</code></div>
</div>
<div class="banner banner-ai">
<div class="banner-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
</div>
<div class="banner-body">
<div class="banner-head">
<span>AI detected outcome</span>
<span class="pill">Success · 92%</span>
</div>
<div class="banner-title">AI thinks the fix resolved the issue — confirm?</div>
<div class="banner-note">Based on your message at 14:31. One click closes the session with this fix as the documented resolution.</div>
</div>
<div class="banner-actions">
<button class="btn btn-ghost">Not yet</button>
<button class="btn btn-danger-outline">No, didn't work</button>
<button class="btn btn-success">Confirm · Resolve</button>
</div>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Triggered by</strong> the new <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">[FIX_OUTCOME fix_id=… outcome=success]</code> marker from the system prompt. Engineer stays in the loop — the AI <em>proposes</em> the outcome, doesn't set it. One-click accept fires the normal Resolve flow. Works for failure too ("still broken" → <em>No, didn't work</em> pre-selected, with the AI's reasoning shown).
</div>
</div>
<!-- C. ESCALATE INTERCEPT -->
<div class="state">
<div class="state-label">C. Escalate-intercept — capture outcome before handoff</div>
<div class="state-body">
<div class="intercept-wrap">
<div class="intercept-popover">
<div class="intercept-head">Before escalating — what happened with the fix?</div>
<div class="intercept-sub">"Clear cached credentials" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.</div>
<div class="intercept-options">
<button class="intercept-option primary">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
The fix didn't work
<span class="intercept-kbd"></span>
</button>
<button class="intercept-option">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
It worked — escalating for another reason
</button>
<button class="intercept-option">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Never actually applied it
</button>
</div>
</div>
</div>
<div class="mock-btn-row">
<button class="mock-escalate active">Escalate</button>
<button class="mock-resolve">Resolve</button>
</div>
</div>
<div class="state-caption">
<strong>Fires when</strong> engineer clicks Escalate while a fix is in Verifying (or Partial). Defaults to <em>Didn't work</em> on Enter — common case. <em>Escalating for another reason</em> preserves success; <em>Never applied</em> flips to <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">dismissed</code>. Takes 1s and makes the escalation narrative honest for whoever picks it up.
</div>
</div>
<!-- D. NUDGE -->
<div class="state">
<div class="state-label">D. Nudge — passive prompt after a few messages</div>
<div class="state-body">
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:6px;opacity:0.8;">
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:70%;">"user is rebooting"</div>
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"okay it's back up, signing in now"</div>
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"going to try opening Outlook"</div>
</div>
<div class="banner banner-nudge">
<svg class="nudge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
<span class="nudge-title">Did <strong style="color:var(--text-heading)">"Clear cached credentials"</strong> work?</span>
<button class="btn btn-ghost" style="padding:4px 10px">Still checking</button>
<button class="btn btn-danger-outline" style="padding:4px 10px">No</button>
<button class="btn btn-success" style="padding:4px 10px">Yes</button>
</div>
<div class="mini-composer">
<div class="mini-input">Type a message…</div>
<button class="mini-send"></button>
</div>
</div>
<div class="state-caption">
<strong>Appears after</strong> ~3 post-apply engineer messages with no outcome click. Collapses the verify banner into this thin nudge strip above it so chat space isn't eaten. Passive — never auto-marks anything. <em>Still checking</em> silences the nudge for another 3 messages. Yes/No route to the normal Success / Failed flows.
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,384 @@
# FlowPilot Phase 9 — Tabbed Script Builder + NoTemplateDialog relocation
**Date:** 2026-04-23
**Branch target:** `feat/flowpilot-migration` (continuation of Phases 08)
**Depends on:** Phase 8 (ProposalBanner in chat region)
---
## Goal
Close the two remaining open items from the FlowPilot migration handoff:
1. **NoTemplateDialog narrow-lane bug** — today the dialog renders in the task lane (~340px) and its `grid-cols-1 sm:grid-cols-3` layout crushes the three option cards. When the AI proposes a fix with no drafted script, all three cards render disabled, producing a dead end.
2. **Tabbed Script Builder inside the chat** — give the engineer a way to draft the missing script without leaving the session (either by chatting with the AI or hand-writing in a code editor), then feed the draft back into the existing fix lifecycle.
Plus two Phase 8 cleanup items flagged during code review:
3. **`EscalateInterceptDialog` missing the Partial choice** — if a fix is in `applied_partial` when the engineer escalates, the intercept dialog's current three choices (worked / didn't work / never applied) don't match. Add a fourth choice for partial.
4. **`applied_at` semantics correction** — today Phase 8's `handleApplyFix` stamps `applied_at` on every banner Apply click, starting the Verifying timer even when the engineer is only opening a drafting/evaluation surface. Move the stamp to the actual run-action handlers (see §5).
This phase depends on Phase 8's `ProposalBanner` already being in the chat region — it reuses the same "chat-region-owns-Apply-flow" philosophy.
---
## Architectural decisions (settled during brainstorming)
| # | Decision | Rationale |
|---|---|---|
| 1 | When a fix has no `ai_drafted_script`, the banner's Apply button routes **directly** to the Script Builder tab (bypassing `NoTemplateDialog` entirely). | Banner is the single entry point for Apply. `NoTemplateDialog` stays narrowly scoped to evaluating a draft that actually exists. |
| 2 | Inside the Script Builder tab, the default experience is AI-driven — a new `ScriptBuilderTab` controller owns session lifecycle + submit, and *renders* `ScriptBuilderChat` (which stays purely presentational). A "✎ Write it myself" button in the tab's header toolbar swaps the controller's render into a Monaco editor. | AI is the common path. Persistence semantics belong on the controller, not the chat display component (`ScriptBuilderChat` already exposes `onSaveScript` as its seam — the controller wires that callback). |
| 3 | The manual editor uses **Monaco**, reusing the pattern from `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx`. | Monaco is already a dependency (`@monaco-editor/react` + `monaco-editor`). No bundle cost, proven pattern. |
| 4 | The Script Builder tab is **always present while the fix is non-terminal** (no close affordance). An **indicator dot** on the tab signals in-progress draft state. | Matches Phase 8's `display: none` philosophy — engineers move freely between chat and draft without tracking a separate open/close state. |
| 5 | `NoTemplateDialog` (draft-exists case) moves from `TaskLane.bottomSlot` to the **chat region** (sibling of `ProposalBanner`, slides up above composer). | Script evaluation is an action surface, not a context surface — belongs with the other action surfaces. Chat region is wide enough for the three cards to actually fit side-by-side. |
| 6 | `EscalateInterceptDialog` gains a **fourth "Partial" choice** that writes `applied_partial` with a notes prompt. | Closes the gap flagged in Phase 8 final review. Minimal incremental cost since the dialog is already getting touched. |
| 7 | `applied_at` is stamped only when the engineer commits to an action that **runs or triggers** a script — not on banner Apply click. Opening a drafting/evaluation surface no longer starts the Verifying timer. | Prevents false "applied" state when the engineer is still authoring. Corrects a Phase 8 over-eager stamp that this phase would otherwise multiply across three surfaces. |
---
## Architecture
### 1. Chat region gets a tab strip
A two-tab strip at the top of the chat region:
```
┌──────────────────────────────────────┐
│ [Chat] [Script Builder ●] │
├──────────────────────────────────────┤
│ │
│ (content per active tab, │
│ via display:none toggling) │
│ │
└──────────────────────────────────────┘
```
- **When the strip renders:** only when an `activeFix` exists AND the fix is non-terminal AND (`fix.ai_drafted_script` is null AND `fix.script_template_id` is null) — i.e., the fix genuinely needs a script drafted. Otherwise the chat region shows without tabs.
- **Tab switching uses `display: none`**, not unmount. Chat scroll position, draft message, and Script Builder state all persist across switches.
- **Indicator dot** on the Script Builder tab fires when there's in-progress draft state: at least one AI message sent in the `ScriptBuilderChat`, or non-empty Monaco buffer. Clears when the draft is submitted.
- **Session switch** clears tab state via the existing `resetSessionDerivedState` helper.
### 2. Script Builder tab content
A new controller component `ScriptBuilderTab` owns the inline lifecycle:
- Creates / resumes a `script_builder_sessions` row with `origin='pilot_inline'` + `ai_session_id = <pilot session id>`.
- Manages AI-chat message state (via the existing script-builder message endpoints) and the Monaco editor buffer.
- On submit, fires `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`.
`ScriptBuilderChat` itself is **unchanged** — it stays a pure display component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. The controller wires `onSaveScript` to its submit path instead of the template-creation path the standalone `/script-builder` page uses.
A header toolbar above the controller's render area hosts the mode toggle:
```
┌──────────────────────────────────────┐
│ Script Builder · Outlook fix │
│ [✎ Write myself]│
├──────────────────────────────────────┤
│ (mode-specific content) │
│ │
└──────────────────────────────────────┘
```
- Clicking **✎ Write myself** flips `scriptBuilderMode` to `'editor'` — the controller renders Monaco in place of `ScriptBuilderChat`, pre-loaded with a scaffold (fix description as a language-appropriate comment header + an empty body).
- A reciprocal **✨ Back to AI** button in editor mode returns to the chat.
- Switching modes **does not discard** work. The Monaco buffer and the script-builder session both persist across toggles. This matters when an engineer drafts with AI, switches to editor to tweak a line, then considers going back.
- Both modes share a single terminal action: the controller's **Submit → `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`**. On success the fix gains `ai_drafted_script`; the tab strip disappears (since the fix no longer needs a script) and the banner's Apply button now routes to `NoTemplateDialog` in the chat region.
- **Submit does NOT stamp `applied_at`.** A draft is not an application — see §5 Apply lifecycle below.
### 3. NoTemplateDialog relocation to chat region
- Removed from `TaskLane.bottomSlot`. Renders in the chat region, slide-up-above-composer (same mechanical placement as `ProposalBanner`).
- The three-card layout (`grid-cols-3` at the chat region's natural width) actually fits — no `grid-cols-1` regression needed.
- Opens when the engineer clicks Apply on the banner AND `fix.ai_drafted_script` is non-empty.
- Decision semantics unchanged (still `one_off` / `draft_template` / `build_template` with the same server-side effects) except for the moved apply stamp — see §5. Only the render location changes beyond that.
### 4. Banner Apply routing (updated)
Three mutually-exclusive outcomes based on the fix's shape:
```
handleApplyFix():
if fix.script_template_id:
open TemplateMatchPanel (unchanged — still renders in task lane for now)
elif fix.ai_drafted_script:
open NoTemplateDialog in chat region (new location, Chat tab)
else:
open Script Builder tab in chat region (new tab)
```
The NoTemplateDialog-in-chat-region path lives on the **Chat tab** (slides up above composer; the tab strip only renders for the no-draft case, so when NoTemplateDialog shows, the tab strip is not on screen). The Script Builder tab path is the opposite — tab strip renders, engineer is on the Script Builder tab.
`TemplateMatchPanel` stays in the task lane for this phase — it's a different surface with different interactions and it's not broken. Moving it is possible future work.
### 5. Apply lifecycle — `applied_at` semantics correction
**Problem.** Today (Phase 8) `handleApplyFix` calls `POST /apply` the moment the banner's Apply button is clicked, stamping `applied_at` regardless of what happens next. This starts the Verifying timer (nudge countdown, Resolve auto-success, Escalate intercept) even if the engineer is only opening a drafting surface and hasn't actually run anything yet. For the no-draft path introduced in this phase, that's clearly wrong — opening the Script Builder tab is the start of *authoring*, not the start of *verifying*.
**Rule.** `applied_at` is stamped **only when the engineer commits to an action that produces or triggers a run**, not when they open a surface:
| Banner Apply click → routes to... | Stamps `applied_at`? |
|---|---|
| `TemplateMatchPanel` (existing flow) | Only when the engineer clicks a new explicit **"✓ I ran this"** action inside the panel (see below) |
| `NoTemplateDialog``one_off` card ("Run now, no template") | **Yes** — the card click declares "I'm running this now" |
| `NoTemplateDialog``draft_template` card ("Run now, templatize after") | **Yes** — same declaration, the template proposal is a side effect |
| `NoTemplateDialog``build_template` card ("Just open the builder") | No — no run is declared; the engineer is going off to author a proper template |
| Script Builder tab → Submit | No — just produces a draft. Engineer then clicks Apply again, gets `NoTemplateDialog`, picks `one_off` or `draft_template` to declare the run |
**New explicit "I ran this" action in `TemplateMatchPanel`.** Today the panel has Generate, Copy, and Edit Parameters — none of which commit to running. Copying doesn't imply running; the engineer can walk away. This phase adds a distinct primary button (accent-colored, below Copy) labeled **"✓ I ran this"** or **"Mark as applied"**. Click → calls `applyFix` → fix transitions to Verifying. Until clicked, the fix stays in `proposed`.
**Implementation.**
- Remove `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix`. Move it to the three run-declaring call sites: `NoTemplateDialog`'s `handleScriptDecision('one_off' | 'draft_template')` paths AND the new `TemplateMatchPanel` "I ran this" button. The `applyFix` endpoint itself (from Phase 8 Issue #2) stays unchanged — only its call sites move.
- Until `applied_at` is stamped, the fix remains in `proposed`. `bannerMode` computation already returns `'proposed'` when `applied_at` is null, so the banner naturally stays on Proposed state through the entire drafting phase.
- **Phase 8 consequence.** This is a semantic revision of Phase 8, not just Phase 9 behavior. Tests must assert: opening `TemplateMatchPanel` does NOT stamp `applied_at`; clicking "I ran this" DOES; `NoTemplateDialog` `one_off` AND `draft_template` both DO; `build_template` does NOT.
### 6. EscalateInterceptDialog partial choice
Adds a fourth button to the existing popover:
| Existing choices | New choice |
|---|---|
| The fix didn't work | (existing) |
| It worked — escalating for another reason | (existing) |
| Never actually applied it | (existing) |
| **I applied some of it — partial** | **NEW** |
- When clicked: prompts for partial notes (same pattern as the banner's Partial path — `window.prompt` for now, matching Phase 8's interim), then calls `patchOutcome('applied_partial', notes)`.
- `handleInterceptChoice` gains an `applied_partial` branch. The `InterceptChoice` type already includes `'applied_partial'` via `FixOutcome | 'never_applied'`, so no type changes needed.
- When a fix enters the dialog already in `applied_partial` state, the fourth button is hidden (can't transition partial → partial with different semantics). The "didn't work" button remains available to progress to `applied_failed`.
---
## Data model
### New migration
`script_builder_sessions` **already has** `ai_session_id` (FK → `ai_sessions.id`, nullable, `ON DELETE SET NULL`) with the comment "Link to FlowPilot session if launched from there." The existing column is the link we need — no new FK is added. The migration introduces only the `origin` discriminator plus a uniqueness guard for inline sessions:
```sql
ALTER TABLE script_builder_sessions
ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'standalone';
ALTER TABLE script_builder_sessions
ADD CONSTRAINT ck_script_builder_sessions_origin
CHECK (origin IN ('standalone', 'pilot_inline'));
-- Invariant: pilot_inline rows must be linked to a pilot session.
-- Standalone rows may or may not be linked (legacy back-channel).
ALTER TABLE script_builder_sessions
ADD CONSTRAINT ck_script_builder_sessions_origin_ai_session
CHECK (origin <> 'pilot_inline' OR ai_session_id IS NOT NULL);
-- Uniqueness: at most one pilot_inline session per (user, pilot session).
-- Required to back the get-or-create semantics on the endpoint and prevent
-- duplicate scratch rows on remount. Partial index scoped to pilot_inline
-- so standalone rows are unaffected.
CREATE UNIQUE INDEX ux_script_builder_sessions_pilot_inline
ON script_builder_sessions (user_id, ai_session_id)
WHERE origin = 'pilot_inline';
```
`origin = 'standalone'` → existing `/script-builder` page usage (existing rows backfill to this default). `origin = 'pilot_inline'` → new Script Builder tab; `ai_session_id` is populated at row creation.
`origin` earns its keep as an explicit discriminator for:
- Filtering (`list_sessions` / `count_user_sessions` exclude `pilot_inline` by default — see §Data model filter changes below).
- Future split-quota billing (decided to count as one billable session for now, but tagged for analytics).
### Data-model filter changes — `script_builder_sessions` list + count
Inline sessions would otherwise pollute the standalone `/script-builder` dashboard and count against the per-user 5-session cap enforced by the `POST /script-builder/sessions` endpoint. Required changes:
- `script_builder_service.list_sessions(user_id)` → default scope `origin = 'standalone'`. Callers that genuinely want all rows (e.g., an admin dashboard in a future phase) can pass an explicit `include_inline=True` flag, but no current caller needs it.
- `script_builder_service.count_user_sessions(user_id)` → same scope.
- Both changes covered by tests:
- 5 `pilot_inline` sessions should still leave the engineer free to create 5 standalone sessions (no cap interaction).
- `list_sessions` returns only `standalone` rows.
### New backend endpoint
```
PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script
```
Request:
```json
{
"ai_drafted_script": "string (required, 1..50_000 chars)",
"ai_drafted_parameters": { /* optional JSONB */ }
}
```
Behavior:
- Auth: `require_engineer_or_admin` + `_load_session_or_404`.
- 404 if fix not found on that session.
- 409 if fix is in a terminal status (`applied_success`, `applied_failed`, `dismissed`) — a drafted script can't be attached after the fix is done.
- Sets `fix.ai_drafted_script` + `fix.ai_drafted_parameters`.
- **Does NOT stamp `fix.applied_at`.** A draft is not an application — see §5 above.
- **Bumps `ai_sessions.state_version`** — the fix just transitioned from "needs drafting" to "has draft", which affects Resolve/Escalate preview regeneration.
- Returns `SessionSuggestedFixResponse`.
### ScriptBuilderTab controller (frontend) — no changes to `ScriptBuilderChat`
`ScriptBuilderChat` (`frontend/src/components/script-builder/ScriptBuilderChat.tsx`) is a presentational component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. **It does not need a `mode` prop** — adding persistence semantics to a display component would be wrong.
Instead, introduce a new controller component `frontend/src/components/pilot/ScriptBuilderTab.tsx` that owns the inline lifecycle:
- On mount: **get-or-create** the single inline `script_builder_sessions` row for `(current user, current pilot session)` via the existing `POST /script-builder/sessions` endpoint, passing `origin: 'pilot_inline'` and the current pilot session id for `ai_session_id`. The endpoint becomes idempotent for `origin='pilot_inline'` — if a row exists for that `(user_id, ai_session_id)` pair, it's returned; otherwise created. The partial unique index on the DB backs the invariant independent of endpoint code. Remounting (tab hide/show, page refresh) resumes the same session — no duplicates, no lost draft continuity.
- Holds local state for the AI message list, the Monaco buffer, and `scriptBuilderMode`.
- Renders `ScriptBuilderChat` in AI mode with `onSaveScript` wired to the inline submit path (PATCH /script), NOT the standalone template-creation path.
- Renders Monaco (via existing `CodeModeEditor` pattern) in `'editor'` mode with its own Save button that triggers the same submit.
- Emits an `onScriptDrafted` event to `AssistantChatPage` on success so the page can `setActiveFix(updated)`, hide the tab strip, and return the engineer to Chat tab.
The standalone `/script-builder` page retains its current behavior unchanged — it continues to create `script_templates` rows on submit. The split happens cleanly at the controller layer, not inside `ScriptBuilderChat`.
### `POST /script-builder/sessions` — changes for inline origin
The existing endpoint is extended in three ways:
1. **Accepts `origin`** in the request body (`Literal['standalone', 'pilot_inline']`, default `'standalone'`). Legacy callers unchanged.
2. **Authorization on `ai_session_id`.** When `origin='pilot_inline'` is passed AND `ai_session_id` is provided, the handler MUST verify the referenced `ai_sessions` row is owned by the current user (or within their account — whichever guard `_load_session_or_404(db, ai_session_id, current_user)` already enforces for the pilot endpoints). Without this check, a caller could attach an inline scratch session to an arbitrary pilot session. The check fires before any row lookup or creation.
3. **Idempotent for `origin='pilot_inline'`.** If a row with `(user_id = current, ai_session_id = provided, origin = 'pilot_inline')` already exists, the handler returns that row (200) instead of creating a new one (201). The unique partial index enforces at-most-one at the DB layer; a race between two concurrent POSTs surfaces as an integrity error that the handler catches and re-reads.
For `origin='standalone'`, behavior is unchanged — always creates, still subject to the 5-session cap.
The 5-session cap applies only to `standalone` rows (see §Data-model filter changes). Inline sessions are out of that accounting entirely.
---
## State
### Frontend state (AssistantChatPage)
New local state on the page:
- `chatTab: 'chat' | 'script_builder'` — which tab is visible. Defaults to `'chat'`.
- `scriptBuilderHasProgress: boolean` — drives the indicator dot. Set by `ScriptBuilderTab` via an `onProgressChange` callback.
Reset in `resetSessionDerivedState`: both back to defaults.
`scriptBuilderMode` ('ai' | 'editor') lives **inside `ScriptBuilderTab`**, not on the page — the parent never needs to drive the AI/editor toggle. The controller resets it naturally via unmount/remount when the page switches sessions.
Banner's Apply handler (`handleApplyFix`) updated:
- If no script + no template → set `chatTab = 'script_builder'` (and show tab strip).
- If drafted script → open NoTemplateDialog in the chat region (new state or existing `scriptPanelOpen` reused).
- If template → open `TemplateMatchPanel` in the task lane (render location unchanged); run stamping happens via the new "I ran this" action inside the panel (see §5), not on Apply click.
### Tab strip visibility
The tab strip is derived, not state:
```ts
const showTabStrip =
activeFix != null &&
activeFix.status !== 'dismissed' &&
activeFix.status !== 'applied_success' &&
activeFix.status !== 'applied_failed' &&
!activeFix.script_template_id &&
!activeFix.ai_drafted_script
```
When the strip hides (e.g., after script is drafted), `chatTab` resets to `'chat'` to avoid stuck state.
### Tab switching guard
The existing `currentChatRef` pattern (Async-select-load-apply guard) applies: when the engineer switches chats, any in-flight tab-derived state is discarded.
---
## Out of scope
- **NoTemplateDialog grid fix.** Moved to the chat region (wide enough), so the `grid-cols-1 sm:grid-cols-3` layout now works as intended. No grid edit required.
- **`window.prompt` replacement** for partial-notes / failure-reason capture. Still the Phase 8 interim pattern; replacement is deferred to a later design debt pass.
- **TemplateMatchPanel relocation** to the chat region. Different surface, different interactions, not broken today. Possible future work.
- **Dedicated "clear AI outcome proposal" button in the UI.** Already covered by Phase 8 Issue #3 fix (DELETE endpoint + clear-on-outcome-write).
- **Task lane bottom-slot audit.** With NoTemplateDialog removed from the slot, it may be empty on most sessions. Keep the slot API stable; any cleanup is out of scope.
---
## Tests
### Backend
- **Migration:** forward + downgrade reversibility; existing rows backfill to `origin='standalone'`; the `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL` invariant is enforced by the check constraint.
- **PATCH /script endpoint** (new test file `test_fix_script_endpoint.py`):
- happy path — 200, `ai_drafted_script` set, `state_version` bumped, `applied_at` untouched.
- 404 on wrong session.
- 409 on terminal status.
- 400 on empty body.
- **list/count filter changes** (extend `test_script_builder.py` or nearby):
- 5 `pilot_inline` sessions + subsequent `standalone` session creation succeeds (does not hit the 5-cap).
- `list_sessions` returns only `standalone` rows by default.
- **Apply lifecycle correction** (extend `test_fix_outcome_endpoint.py`):
- Banner Apply click that routes to a drafting/evaluation surface does NOT stamp `applied_at`.
- `one_off` decision from `NoTemplateDialog` DOES stamp `applied_at`.
- `draft_template` decision from `NoTemplateDialog` DOES stamp `applied_at` (it still runs the script).
- `build_template` decision from `NoTemplateDialog` does NOT stamp (no run).
- `TemplateMatchPanel` "I ran this" action DOES stamp `applied_at`; Generate / Copy alone do NOT.
- **Script Builder session create — inline semantics** (extend `test_script_builder.py` or equivalent):
- First `POST /script-builder/sessions` with `origin='pilot_inline', ai_session_id=X` creates and returns a row.
- Second `POST` with the same `(ai_session_id, user)` returns the SAME row (no duplicate created); DB row count confirms.
- `POST` with `origin='pilot_inline'` and `ai_session_id` pointing at another user's pilot session is rejected (403/404).
- Race: two concurrent `POST`s for the same `(user, ai_session_id)` resolve to the same row id (one winner, one returns the existing).
### Frontend
Manual verification (no component test harness in this codebase per CLAUDE.md):
- No-draft fix → Apply click opens Script Builder tab.
- AI path: chat with AI, submit, tab disappears, NoTemplateDialog becomes eligible.
- Manual path: ✎ Write myself → Monaco loads with scaffold → edit → submit → tab disappears.
- Drafted fix → Apply click opens NoTemplateDialog in chat region (three cards side-by-side).
- Tab indicator dot appears on first AI message / non-empty Monaco buffer; clears on submit.
- Session switch with open Script Builder tab → tab/mode state resets.
- EscalateInterceptDialog partial choice → applied_partial written with notes.
### Build discipline
- `tsc -b` clean
- `npm run build` clean
- `docker exec resolutionflow_backend pytest` — all pre-existing suites still pass, no regression from the new endpoint.
---
## Files to touch (rough inventory)
**Backend — new:**
- `backend/alembic/versions/<hash>_script_builder_origin.py`
- `backend/tests/test_fix_script_endpoint.py`
**Backend — modified:**
- `backend/app/models/script_builder_session.py` — add `origin` column only (`ai_session_id` already exists).
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest`.
- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with two new optional fields: `origin: Literal['standalone', 'pilot_inline'] = 'standalone'` and `ai_session_id: UUID | None = None`. Handler-side validation: when `origin='pilot_inline'`, `ai_session_id` is required (not null) AND must pass the current-user ownership check. Legacy callers pass neither and continue to create standalone sessions as before.
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint. Move the existing `applied_at` stamp out of the apply path and into `handleScriptDecision('one_off' | 'draft_template')` plus `TemplateMatchPanel`'s new "I ran this" handler (server side: no change to `/apply`; callers shift instead).
- `backend/app/api/endpoints/script_builder.py` — accept `origin` on session creation; enforce the `pilot_inline ⇒ ai_session_id` invariant at the handler level.
- `backend/app/services/script_builder_service.py` — persist `origin`; `list_sessions` + `count_user_sessions` filter to `origin='standalone'` by default.
- `backend/app/models/session_suggested_fix.py` — unchanged (schema already has `ai_drafted_script`).
**Frontend — new:**
- `frontend/src/components/pilot/ChatTabStrip.tsx` — renders the `[Chat] [Script Builder ●]` strip.
- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller that owns session lifecycle, AI message state, Monaco buffer, mode toggle, and submit. Renders `ScriptBuilderChat` in AI mode and Monaco in editor mode.
- `frontend/src/components/pilot/NoTemplateDialogInline.tsx` (or reuse existing `NoTemplateDialog` with a new wrapper for chat-region styling).
**Frontend — modified:**
- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchScript(sessionId, fixId, body, parameters)` method.
- `frontend/src/api/scriptBuilder.ts` (or equivalent) — `createSession` accepts optional `origin` and `ai_session_id` arguments (both required together when the caller is `ScriptBuilderTab`; both omitted for the legacy standalone caller).
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx`**unchanged**. Stays a pure display component.
- `frontend/src/pages/ScriptBuilderPage.tsx`**unchanged on the session-creation path** (defaults to `origin='standalone'`).
- `frontend/src/pages/AssistantChatPage.tsx` — wire tab strip, mount `ScriptBuilderTab`, banner Apply routing (no `applied_at` stamp on click), NoTemplateDialog chat-region render. Move the `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix` to `handleScriptDecision('one_off' | 'draft_template')` and `TemplateMatchPanel`'s new "I ran this" handler.
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — add fourth choice.
- `frontend/src/components/pilot/TaskLane.tsx` — remove `bottomSlot` usage of NoTemplateDialog (leave prop API stable).
**Frontend — deleted:**
- None (existing components get refactored, not deleted).
---
## Rollout
- Single branch, merged as part of the in-flight `feat/flowpilot-migration` PR (same as Phase 8).
- No feature flag — the new surface is strictly additive to the banner's Apply flow; old behavior for drafted-script fixes is preserved (just renders in a different location).
---
## Open deferrals (acknowledged, not in this phase)
- `window.prompt` → inline input migration for partial notes / failure reasons.
- Anti-parrot compliance check for the inline `ScriptBuilderTab` flow — verify it reuses the existing script-builder AI system prompt (no new prompt content introduced; the controller only changes what `onSaveScript` does, not what the AI sees).
- Telemetry events for tab opens / AI→editor toggles / script submissions from tab — add in the Phase 9 implementation plan if we want them.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
---
date: 2026-04-22
branch: feat/flowpilot-migration
remote: ssh://gitea.resolutionflow.com/chihlasm/resolutionflow.git
last_commit: faf1d8d fix(pilot): applied_at stamps on run-declaring actions, not Apply click
status: Sprint 9/9 phases complete and pushed; PR not yet opened. Open items #1 and #3 resolved by Phase 9.
---
# FlowPilot Migration — Session Handoff
## Where the work lives
- Branch: `feat/flowpilot-migration` (pushed to Gitea, mirrors to GitHub)
- Spec: [docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md](../FlowAssist_Migration/FLOWPILOT-MIGRATION.md)
- Mockups: [docs/FlowAssist_Migration/mockups/](../FlowAssist_Migration/mockups/) (PNG + HTML reference)
## What shipped
All nine migration phases are merged onto the branch and verified against the live dev stack (`resolutionflow_frontend` / `resolutionflow_backend` / `resolutionflow_postgres` containers).
| Phase | Commit | What landed |
|---|---|---|
| 0 — baseline telemetry | (pre-branch) | analytics events for funnel deltas |
| 1 — `/assistant``/pilot` rename | early commits | route redirects, sidebar updates |
| 2 — What we know (facts) | (mid) | `session_facts` table, `[PROMOTE]` marker, fact CRUD endpoints, `WhatWeKnow` section |
| 3 — Suggested fix + Resolve preview | `7ccf4c6` and prior | `session_suggested_fixes`, `[SUGGEST_FIX]` marker, `ResolutionNotePreview` popover |
| 4 — Escalate + PSA writeback | `8fd2c1b` | `psa_writeback_service` with status verification, kind-parameterized preview |
| 5 — inline Script Generator | `fa61376` | `TemplateMatchPanel`, `NoTemplateDialog` three-option dialog |
| 6 — post-resolve templatize | `4aaf57a` | `draft_templates` table, accept/reject endpoints, `TemplatizePrompt` modal, account preferences |
| 7 — polish | `8a242f5` | loading/empty states, keyboard shortcuts (`⌘↵`, `⌘G`, `?` overlay), responsive bottom-drawer <1200px |
| 8 — Fix Outcome Banner | `cdd8bb0`..`a47ce07` | Six outcome columns on `session_suggested_fixes` (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`) + `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/outcome` endpoint + `[FIX_OUTCOME]` marker; replaces task-lane `SuggestedFix` card with a chat-composer-anchored `ProposalBanner` (5 states: proposed/verifying/partial/ai_confirming/nudge + collapsed); `EscalateInterceptDialog` captures outcome before handoff; Resolve-while-verifying auto-marks success; 17 new tests (8 endpoint + 7 marker + 2 anti-parrot) |
| 9 — Tabbed Script Builder | `5bcb7aa`..`faf1d8d` | Chat-region tab strip (`[Chat] [Script Builder ●]`) with `ChatTabStrip` + new `ScriptBuilderTab` controller wrapping the existing `ScriptBuilderChat` + Monaco editor (`ScriptBodyEditor`); `InlineNoTemplateDialog` relocates the existing `NoTemplateDialog` from the narrow task-lane `bottomSlot` to a chat-region placement wrapper; `EscalateInterceptDialog` gains a fourth "partial" choice; `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint for engineer-drafted scripts (does not stamp `applied_at`); Alembic migration adds `origin VARCHAR(20)` to `script_builder_sessions` (reuses existing `ai_session_id` FK) + partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` for idempotent get-or-create; `applied_at` semantics corrected to stamp only on run-declaring actions (`handleScriptDecision` for `one_off`/`draft_template`; new `onMarkRun` on `TemplateMatchPanel`) — not the Apply click |
Plus the structural fixes that came up along the way:
- `50215b9` + `d0ebdef` — full sweep removing literal payloads from AI system prompts; new `tests/test_prompt_anti_parrot.py` guardrail
- `ce7c8ac` + `ddae171` — task-lane state-leak across chats (centralized `resetSessionDerivedState()` helper)
- `8879f96` — dropped `sticky top-0` from all four lane section headers (they were orphaning over unrelated content on scroll)
## How to resume
1. `git checkout feat/flowpilot-migration`
2. `docker compose -f docker-compose.dev.yml up -d` (if the stack isn't running)
3. Verify: `docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"` should be clean
4. Live URL: <http://localhost:5173/pilot> (or `<host-ip>:5173/pilot`)
5. Test users (password `TestPass123!`): `engineer@resolutionflow.example.com`
## Open work — pick one
Items #1 and #3 were discovered during Phase 6/7 verification. Item #2 was resolved by Phase 8. Items #1 and #3 are **resolved by Phase 9** (see below).
### 1. NoTemplateDialog narrow-lane bug
**Status: RESOLVED by Phase 9.**
Phase 9 relocated `InlineNoTemplateDialog` from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper (`InlineNoTemplateDialog.tsx`). The dialog no longer renders inside the narrow 380px task lane, eliminating the `sm:grid-cols-3` viewport-breakpoint collision. The disabled-cards bug (when no `ai_drafted_script` is present) is also resolved: when no draft exists, the engineer is routed into the new `ScriptBuilderTab` inline chat instead of reaching the three-option dialog with disabled cards.
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
### 2. Task lane crowding / Suggested Fix discoverability
**Status: RESOLVED by Phase 8.** The `SuggestedFix` card no longer lives inside the scrollable task lane. Phase 8 replaced it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that is always visible at the bottom of the conversation column regardless of how far the task lane has scrolled. The banner is the primary entry point for fix application; the task lane retains a compact read-only summary of the active fix for reference.
See [docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md](../FlowAssist_Migration/phase-8-fix-outcome-banner.md) for the implementation plan and design rationale. Because the banner is now the primary entry point, the NoTemplateDialog narrow-lane bug (open item #1) is considerably less visible — the three-option dialog is only reached after the engineer opts in via the banner, at which point they have already acknowledged the fix.
### 3. Tabbed Script Builder inside the chat (Option A from the modal-vs-tab discussion)
**Status: RESOLVED by Phase 9.**
Phase 9 shipped the complete tabbed Script Builder integration. The chat region now has a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip`) powered by a new `ScriptBuilderTab` controller that wraps the existing (untouched) `ScriptBuilderChat` for AI mode and `ScriptBodyEditor` (Monaco) for a "Write it myself" editor mode. `display: none` toggling preserves chat scroll position, draft message, and editor buffer across tab switches.
The `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint writes `ai_drafted_script` + `ai_drafted_parameters` back to the fix record without stamping `applied_at` — a draft is not an application. Bumps `state_version` so cached Resolve/Escalate previews regenerate.
The migration added `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` (with CHECK constraint on the two valid values + invariant that `origin='pilot_inline'` requires `ai_session_id IS NOT NULL`) to `script_builder_sessions`. It reuses the pre-existing `ai_session_id` FK rather than adding a new parent column. A partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency from the inline tab.
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
## Loose ends / things to verify on resume
- **PR not opened.** Branch is pushed but no Gitea PR yet. When ready: `gh pr create` works against the GitHub mirror, but the actual review happens in Gitea.
- **`/ultrareview` not run** on the final state of the branch (including Phase 9). Worth doing before PR creation.
- **Phase 9 browser QA not done.** The new tab strip, `ScriptBuilderTab` (AI + editor modes), `InlineNoTemplateDialog` chat-region placement, and `EscalateInterceptDialog` fourth-choice flow have not been exercised in a headless-browser session. Key states to cover: tab strip renders and toggles without unmounting chat or losing editor buffer; Script Builder tab Submit persists script via PATCH without stamping `applied_at`; `one_off`/`draft_template` decisions DO stamp; `build_template` does NOT stamp; `TemplateMatchPanel` "I ran this" stamps via `onMarkRun`; partial-attempt choice in `EscalateInterceptDialog` is recorded correctly.
- **Phase 8 browser QA not done.** The `ProposalBanner` and `EscalateInterceptDialog` (three-choice variant) have not been exercised in a headless-browser session. Key states: banner appears on `[FIX_OUTCOME]` marker; banner dismisses correctly; escalate mid-fix triggers dialog; banner auto-collapses after session resolved. Use `/qa` or `/design-review` against `mockups/06-slide-up-banner.html` and `mockups/07-verify-states.html`.
- **Phase 7 visual verification was structural only** — `tsc -b` and `npm run build` both clean, HMR applied each change without error, but no headless-browser screenshot comparison against the mockup PNGs. If you want pixel-level verification, `/qa` or `/design-review` would catch deltas.
- **Anti-parrot test runs as part of `pytest`** but is not enforced in any specific CI step yet — verify `tests/test_prompt_anti_parrot.py` is discovered by the existing pytest run, and consider failing CI explicitly on regression.
## Files most likely to need attention next
- [frontend/src/pages/AssistantChatPage.tsx](../../frontend/src/pages/AssistantChatPage.tsx) — 1500+ lines, the central pilot orchestrator. Most state-leak and rendering bugs surface here first. Search for `resetSessionDerivedState` to see the chat-switch reset pattern.
- [frontend/src/components/assistant/TaskLane.tsx](../../frontend/src/components/assistant/TaskLane.tsx) — accepts `whatWeKnowSlot` / `bottomSlot` from the parent, plus a `variant: 'side' | 'drawer'` for responsive. `bottomSlot` remains active (carries `TemplateMatchPanel` + resolve/escalate preview buttons in both side and drawer variants).
- [backend/app/services/unified_chat_service.py](../../backend/app/services/unified_chat_service.py) — owns marker parsing for `[PROMOTE]`, `[SUGGEST_FIX]`, `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[TREE_UPDATE]`. If markers stop firing in chat, this is the first place to check.
- [backend/app/services/assistant_chat_service.py](../../backend/app/services/assistant_chat_service.py) — `ASSISTANT_SYSTEM_PROMPT` constant. Anti-parrot test enforces no literal payloads here; use `<placeholder>` syntax only.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,21 @@ import type {
} from '@/types'
import type { ScriptTemplateDetail } from '@/types'
export interface CreateSessionOptions {
origin?: 'standalone' | 'pilot_inline'
aiSessionId?: string
}
export const scriptBuilderApi = {
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
async createSession(
language: string,
options?: CreateSessionOptions,
): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.post('/scripts/builder/sessions', {
language,
origin: options?.origin,
ai_session_id: options?.aiSessionId,
})
return data
},

View File

@@ -8,6 +8,25 @@ import apiClient from './client'
export type UserDecision = 'one_off' | 'draft_template' | 'build_template' | 'dismissed'
export type FixStatus =
| 'proposed'
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'dismissed'
export type FixOutcome =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
| 'dismissed'
export interface AIOutcomeProposal {
fix_id: string
outcome: 'success' | 'failure' | 'partial'
reason: string
}
export interface SessionSuggestedFix {
id: string
session_id: string
@@ -18,6 +37,12 @@ export interface SessionSuggestedFix {
ai_drafted_script: string | null
ai_drafted_parameters: Record<string, unknown> | null
user_decision: UserDecision | null
status: FixStatus
applied_at: string | null
verified_at: string | null
partial_notes: string | null
failure_reason: string | null
ai_outcome_proposal: AIOutcomeProposal | null
superseded_at: string | null
created_at: string
}
@@ -86,6 +111,40 @@ export const sessionSuggestedFixesApi = {
return r.data
},
/**
* Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
* Does NOT change status (fix remains 'proposed'). Status flips only on
* a subsequent PATCH /outcome. Idempotent if applied_at is already set.
* Returns 409 if the fix is no longer in 'proposed' status.
*/
async applyFix(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
const r = await apiClient.post<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/apply`,
)
return r.data
},
/**
* Record the outcome of applying a suggested fix. Transition rules:
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
* parked, not terminal — engineer may update notes, abandon via dismiss,
* or advance to success/failed).
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
* server returns 409.
*/
async patchOutcome(
sessionId: string,
fixId: string,
outcome: FixOutcome,
notes?: string,
): Promise<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`,
{ outcome, notes },
)
return r.data
},
/**
* Fetch (or get cached) draft markdown for the Resolve note. Backend cache
* is keyed on state_version, so calling this back-to-back without intervening
@@ -137,6 +196,40 @@ export const sessionSuggestedFixesApi = {
)
return r.data
},
/**
* Attach an engineer-drafted script to a suggested fix (inline Script
* Builder Submit path). Does NOT stamp applied_at — the server treats
* a draft as non-terminal progress. Bumps state_version so the
* Resolve/Escalate preview regenerates.
*/
async patchScript(
sessionId: string,
fixId: string,
aiDraftedScript: string,
aiDraftedParameters?: Record<string, unknown>,
): Promise<SessionSuggestedFix> {
const r = await apiClient.patch<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`,
{
ai_drafted_script: aiDraftedScript,
ai_drafted_parameters: aiDraftedParameters,
},
)
return r.data
},
/**
* Explicitly dismiss the AI-proposed outcome banner ("Not yet").
* Clears ai_outcome_proposal on the server without touching status or
* state_version. Idempotent: returns 200 even when the field is already null.
*/
async clearAIProposal(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
const r = await apiClient.delete<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/ai-outcome-proposal`,
)
return r.data
},
}
export default sessionSuggestedFixesApi

View File

@@ -0,0 +1,49 @@
import { apiClient } from './client'
import type {
PSAResource,
PSATicketCreated,
PSATicketStatusUpdate,
TicketCreationPayload,
AiParseResponse,
TicketListResponse,
PSAPriority,
} from '@/types/tickets'
export const ticketsApi = {
listResources: (ticketId: number): Promise<PSAResource[]> =>
apiClient.get<PSAResource[]>(`/integrations/psa/tickets/${ticketId}/resources`).then(r => r.data),
addResource: (ticketId: number, memberId: number): Promise<PSAResource> =>
apiClient.post<PSAResource>(`/integrations/psa/tickets/${ticketId}/resources?member_id=${memberId}`).then(r => r.data),
removeResource: (ticketId: number, memberId: number): Promise<void> =>
apiClient.delete(`/integrations/psa/tickets/${ticketId}/resources/${memberId}`).then(() => undefined),
updateStatus: (ticketId: number, statusId: number): Promise<PSATicketStatusUpdate> =>
apiClient.patch<PSATicketStatusUpdate>(`/integrations/psa/tickets/${ticketId}/status?status_id=${statusId}`).then(r => r.data),
createTicket: (payload: TicketCreationPayload): Promise<PSATicketCreated> =>
apiClient.post<PSATicketCreated>('/integrations/psa/tickets', payload).then(r => r.data),
aiParse: (prompt: string): Promise<AiParseResponse> =>
apiClient.post<AiParseResponse>('/integrations/psa/tickets/ai-parse', { prompt }).then(r => r.data),
listPriorities: (): Promise<PSAPriority[]> =>
apiClient.get<PSAPriority[]>('/integrations/psa/priorities').then(r => r.data),
searchTickets: (params: {
query?: string
board_id?: number | null
status_id?: number | null
status_name?: string | null
include_closed?: boolean
assigned_to_me?: boolean
unassigned?: boolean
board_ids?: string
priority?: string | null
company_id?: number | null
page?: number
page_size?: number
}): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
}

View File

@@ -43,8 +43,6 @@ interface TaskLaneProps {
// shape lets the parent own fact-fetching and state-version polling without
// pulling that concern into TaskLane.
whatWeKnowSlot?: React.ReactNode
// Phase 3: Suggested fix card, rendered below Diagnostic Checks.
suggestedFixSlot?: React.ReactNode
// Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover
// (parent owns state). Renders inside the scrollable body so the popover
// stays anchored as the lane scrolls.
@@ -79,7 +77,7 @@ export function clearTaskState(sessionId: string) {
// ── Component ──
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
const isDrawer = variant === 'drawer'
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
// Try to restore saved state for this session (preserves user's in-progress answers)
@@ -535,15 +533,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</section>
)}
{/* ── Suggested fix (Phase 3) ── */}
{suggestedFixSlot}
{/* Quiet-state hint: lane is open (facts exist), but AI hasn't
proposed a next step yet. Keeps the lane from feeling "finished"
when the engineer still expects a question / fix to arrive. */}
{questionTasks.length === 0
&& actionTasks.length === 0
&& !suggestedFixSlot
&& !loading && (
<div className="text-[0.6875rem] italic text-muted-foreground px-1 py-2">
No open questions send a message or add a note; the AI will follow up.

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
import { useNavigate, Link } from 'react-router-dom'
import { Ticket, ChevronDown, Check, AlertCircle } from 'lucide-react'
import { integrationsApi } from '@/api/integrations'
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
import { cn } from '@/lib/utils'
const PAGE_SIZE = 10
const PAGE_SIZE = 5
type Tab = 'mine' | 'unassigned'
@@ -188,14 +188,15 @@ function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
export function TicketQueue() {
const navigate = useNavigate()
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
const [boards, setBoards] = useState<PSABoard[]>([])
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<Tab>('mine')
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
// Monotonically increasing fetch token — late responses with a stale id
// are dropped so they can't overwrite the latest query's results.
const latestRequestId = useRef(0)
const [error, setError] = useState<string | null>(null)
// Check connection on mount
@@ -208,6 +209,15 @@ export function TicketQueue() {
.catch(() => setHasConnection(false))
}, [])
// Detect member mapping on mount
useEffect(() => {
integrationsApi.getMemberMappings()
.then(mappings => {
setHasMemberMapping(mappings.length > 0)
})
.catch(() => setHasMemberMapping(false))
}, [])
// Fetch boards once connection confirmed
useEffect(() => {
if (!hasConnection) return
@@ -217,9 +227,9 @@ export function TicketQueue() {
}, [hasConnection])
const fetchTickets = useCallback(
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
async (tab: Tab, boardIds: number[]) => {
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
page: pageNum,
page: 1,
page_size: PAGE_SIZE,
}
if (tab === 'mine') {
@@ -231,17 +241,25 @@ export function TicketQueue() {
params.board_ids = boardIds.join(',')
}
// Clear stale data + flip loading inside the async function so the
// writes happen after the awaitable boundary — avoids the
// synchronous-setState-in-effect cascade the lint rule flags. The
// fetch is also wrapped in a request-id check so a stale response
// can't clobber a newer query.
const requestId = ++latestRequestId.current
setTickets([])
setLoading(true)
try {
const results = await integrationsApi.searchTicketsQueue(params)
if (append) {
setTickets((prev) => [...prev, ...results])
} else {
setTickets(results)
}
setHasMore(results.length === PAGE_SIZE)
if (requestId !== latestRequestId.current) return
setTickets(results.items)
setError(null)
} catch {
if (requestId !== latestRequestId.current) return
setError('Failed to load tickets. Check your PSA connection.')
} finally {
if (requestId === latestRequestId.current) setLoading(false)
}
},
[],
@@ -250,20 +268,9 @@ export function TicketQueue() {
// Initial + reset fetch when tab or board selection changes
useEffect(() => {
if (!hasConnection) return
setPage(1)
setTickets([])
setHasMore(false)
setLoading(true)
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
const handleLoadMore = async () => {
const nextPage = page + 1
setPage(nextPage)
setLoadingMore(true)
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
setLoadingMore(false)
}
if (activeTab === 'mine' && hasMemberMapping !== true) return
fetchTickets(activeTab, selectedBoardIds)
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
const handleStartSession = (ticket: PSATicketSearchResult) => {
navigate('/pilot', {
@@ -327,6 +334,18 @@ export function TicketQueue() {
{/* Content */}
<div>
{/* Mapping prompt for "mine" tab when no member mapping configured */}
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
@@ -345,13 +364,25 @@ export function TicketQueue() {
<TicketRow
key={ticket.id}
ticket={ticket}
isLast={i === tickets.length - 1 && !hasMore}
isLast={i === tickets.length - 1}
onStartSession={handleStartSession}
/>
))}
</>
)}
{/* View all tickets link */}
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets
</Link>
</div>
)}
{/* Empty states */}
{!error && !loading && tickets.length === 0 && (
<div className="px-5 py-8 text-center">
@@ -369,28 +400,6 @@ export function TicketQueue() {
</div>
)}
{/* Load more */}
{!error && !loading && hasMore && (
<div
className="px-5 py-3"
style={{ borderTop: '1px solid var(--color-border-default)' }}
>
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
>
{loadingMore ? (
<>
<Loader2 size={12} className="animate-spin" />
Loading...
</>
) : (
'Load more'
)}
</button>
</div>
)}
</div>
</div>
)

View File

@@ -5,7 +5,7 @@ import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
ListChecks, Download, BarChart3,
Settings, Pin, PinOff,
History, FileText, Network,
History, FileText, Network, Ticket,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -94,6 +94,10 @@ export function Sidebar() {
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
],
},
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
@@ -132,6 +136,7 @@ export function Sidebar() {
items: [
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
],
},

View File

@@ -62,10 +62,9 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
}
}, [editing])
// Sync if data.label changes externally (e.g. undo/redo)
useEffect(() => {
if (!editing) setLabelValue(nodeData.label ?? '')
}, [nodeData.label, editing])
// While not editing, the displayed label is derived directly from
// nodeData.label — no effect-driven sync needed. labelValue holds the
// edit buffer only and is reset when an edit session starts.
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
@@ -127,10 +126,11 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
onDoubleClick={e => {
e.stopPropagation()
setLabelValue(nodeData.label ?? '')
setEditing(true)
}}
>
{labelValue}
{nodeData.label ?? ''}
</span>
)}
<span

View File

@@ -22,10 +22,9 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
if (editing) inputRef.current?.focus()
}, [editing])
// Sync if external data.label changes
useEffect(() => {
if (!editing) setLabelValue(groupData.label ?? '')
}, [groupData.label, editing])
// While not editing, the displayed label is derived directly from
// groupData.label — no effect-driven sync needed. labelValue holds the
// edit buffer only and is reset when an edit session starts.
const handleLabelCommit = () => {
setEditing(false)
@@ -69,9 +68,12 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
<span
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
style={{ color }}
onDoubleClick={() => setEditing(true)}
onDoubleClick={() => {
setLabelValue(groupData.label ?? '')
setEditing(true)
}}
>
{labelValue || groupData.groupType}
{(groupData.label ?? '') || groupData.groupType}
</span>
)}
</div>

View File

@@ -0,0 +1,79 @@
/**
* ChatTabStrip — two-tab strip at the top of the chat region:
* [Chat] [Script Builder ●]
*
* Visibility is controlled by the parent (AssistantChatPage) — this
* component renders whenever it's mounted. The parent decides whether
* to mount it based on fix state.
*
* Tab switching uses onChange; the parent toggles display:none on the
* tab contents so state is preserved across switches.
*/
import { cn } from '@/lib/utils'
export type ChatTab = 'chat' | 'script_builder'
export interface ChatTabStripProps {
active: ChatTab
onChange: (tab: ChatTab) => void
/** When true, shows the amber indicator dot on the Script Builder tab. */
scriptBuilderHasProgress?: boolean
}
export function ChatTabStrip({
active, onChange, scriptBuilderHasProgress,
}: ChatTabStripProps) {
return (
<div
role="tablist"
className="flex gap-1 px-4 pt-2 border-b border-default bg-bg-sidebar"
>
<TabButton
label="Chat"
active={active === 'chat'}
onClick={() => onChange('chat')}
/>
<TabButton
label="Script Builder"
active={active === 'script_builder'}
onClick={() => onChange('script_builder')}
indicator={scriptBuilderHasProgress}
/>
</div>
)
}
function TabButton({
label, active, onClick, indicator,
}: {
label: string
active: boolean
onClick: () => void
indicator?: boolean
}) {
return (
<button
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
'border-b-2 -mb-px',
active
? 'text-heading border-accent bg-bg-page'
: 'text-muted-foreground border-transparent hover:text-primary hover:bg-white/[0.08]',
)}
>
{label}
{indicator && (
<span
role="img"
aria-label="unsaved progress"
className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
/>
)}
</button>
)
}
export default ChatTabStrip

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