3 Commits

Author SHA1 Message Date
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
4 changed files with 141 additions and 54 deletions

View File

@@ -14,21 +14,33 @@ from sqlalchemy.pool import NullPool
from app.main import app from app.main import app
from app.core.database import Base, get_db from app.core.database import Base, get_db
from app.core.admin_database import get_admin_db
from app.core.config import settings from app.core.config import settings
# Disable invite code requirement for tests # Disable invite code requirement for tests
settings.REQUIRE_INVITE_CODE = False settings.REQUIRE_INVITE_CODE = False
# Test database URL (separate from production) # Test database URL — NEVER reuse DATABASE_URL. The test_db fixture does
# Use DATABASE_TEST_URL env var if set (e.g. inside Docker where host is 'db'), # `DROP SCHEMA public CASCADE` on every test; if DATABASE_URL (which normally
# otherwise fall back to localhost for local development. # 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".
import os import os
TEST_DATABASE_URL = os.environ.get( TEST_DATABASE_URL = os.environ.get(
"DATABASE_URL", "DATABASE_TEST_URL",
os.environ.get( "postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
"DATABASE_TEST_URL", )
"postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test",
), # 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)."
) )
@@ -131,6 +143,11 @@ async def client(test_db: AsyncSession):
yield test_db yield test_db
app.dependency_overrides[get_db] = override_get_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) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac: async with AsyncClient(transport=transport, base_url="http://test") as ac:

View File

@@ -33,6 +33,9 @@ services:
- DEBUG=true - DEBUG=true
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
- DATABASE_URL_SYNC=postgresql://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} - SECRET_KEY=${SECRET_KEY}
- ALGORITHM=HS256 - ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=15 - ACCESS_TOKEN_EXPIRE_MINUTES=15

View File

@@ -8,14 +8,16 @@
* Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html * Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html
* (panel C). * (panel C).
*/ */
import { useState } from 'react'
import { X, AlertCircle, Check, Info } from 'lucide-react' import { X, AlertCircle, Check, Info } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { FixOutcome } from '@/api/sessionSuggestedFixes' import type { FixOutcome } from '@/api/sessionSuggestedFixes'
export type InterceptChoice = FixOutcome | 'never_applied' export type InterceptChoice = FixOutcome | 'never_applied'
export interface EscalateInterceptDialogProps { export interface EscalateInterceptDialogProps {
fixTitle: string fixTitle: string
onChoose: (choice: InterceptChoice) => void onChoose: (choice: InterceptChoice, notes?: string) => void
onClose: () => void onClose: () => void
} }
@@ -24,6 +26,11 @@ export function EscalateInterceptDialog({
onChoose, onChoose,
onClose, onClose,
}: EscalateInterceptDialogProps) { }: EscalateInterceptDialogProps) {
const [partialStep, setPartialStep] = useState(false)
const [partialNotes, setPartialNotes] = useState('')
const notesValid = partialNotes.trim().length > 0
return ( return (
<> <>
<div <div
@@ -36,45 +43,86 @@ export function EscalateInterceptDialog({
aria-label="Capture fix outcome before escalating" aria-label="Capture fix outcome before escalating"
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]" className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
> >
<div className="font-heading font-semibold text-[13px] text-heading mb-1"> {!partialStep ? (
Before escalating what happened with the fix? <>
</div> <div className="font-heading font-semibold text-[13px] text-heading mb-1">
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3"> Before escalating what happened with the fix?
&ldquo;{fixTitle}&rdquo; is still in the Verifying state. Tag its outcome so </div>
the senior picking this up knows what&apos;s been tried. <div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
</div> &ldquo;{fixTitle}&rdquo; is still in the Verifying state. Tag its outcome so
<div className="flex flex-col gap-1.5"> the senior picking this up knows what&apos;s been tried.
<button </div>
autoFocus <div className="flex flex-col gap-1.5">
onClick={() => onChoose('applied_failed')} <button
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left" autoFocus
> onClick={() => onChoose('applied_failed')}
<X size={13} strokeWidth={2.5} className="text-danger" /> className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left"
<span className="flex-1">The fix didn&apos;t work</span> >
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]"></span> <X size={13} strokeWidth={2.5} className="text-danger" />
</button> <span className="flex-1">The fix didn&apos;t work</span>
<button <span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]"></span>
onClick={() => onChoose('applied_partial')} </button>
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left" <button
> onClick={() => setPartialStep(true)}
<Info size={13} strokeWidth={2} /> className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
<span className="flex-1">I applied some of it partial</span> >
</button> <Info size={13} strokeWidth={2} />
<button <span className="flex-1">I applied some of it partial</span>
onClick={() => onChoose('applied_success')} </button>
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left" <button
> onClick={() => onChoose('applied_success')}
<Check size={13} strokeWidth={2} /> className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
<span className="flex-1">It worked escalating for another reason</span> >
</button> <Check size={13} strokeWidth={2} />
<button <span className="flex-1">It worked escalating for another reason</span>
onClick={() => onChoose('never_applied')} </button>
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left" <button
> onClick={() => onChoose('never_applied')}
<AlertCircle size={13} strokeWidth={2} /> className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
<span className="flex-1">Never actually applied it</span> >
</button> <AlertCircle size={13} strokeWidth={2} />
</div> <span className="flex-1">Never actually applied it</span>
</button>
</div>
</>
) : (
<>
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
What partially worked?
</div>
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
A short note for whoever picks this up what you tried, what worked, what&apos;s still broken.
</div>
<textarea
autoFocus
value={partialNotes}
onChange={(e) => setPartialNotes(e.target.value)}
placeholder="e.g. Cleared cached creds; rebuild profile still hung on sync."
className="w-full h-24 resize-none rounded-md border border-white/10 bg-elevated px-2.5 py-2 text-[12.5px] text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent"
/>
<div className="flex items-center justify-between gap-2 mt-3">
<button
onClick={() => {
setPartialStep(false)
setPartialNotes('')
}}
className="text-[12px] text-muted-foreground hover:text-primary transition-colors"
>
Back
</button>
<button
onClick={() => onChoose('applied_partial', partialNotes.trim())}
disabled={!notesValid}
className={cn(
'px-3 py-1.5 rounded text-[12.5px] font-semibold transition-colors',
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
Record partial &amp; escalate
</button>
</div>
</>
)}
</div> </div>
</> </>
) )

View File

@@ -656,19 +656,33 @@ export default function AssistantChatPage() {
setShowConclude(true) setShowConclude(true)
}, [activeFix]) }, [activeFix])
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => { const handleInterceptChoice = useCallback(async (choice: InterceptChoice, notes?: string) => {
const stored = escalateIntercept const stored = escalateIntercept
setEscalateIntercept(null) if (!stored || !activeChatId) {
if (!stored || !activeChatId) return setEscalateIntercept(null)
return
}
const outcomeToSend: FixOutcome = const outcomeToSend: FixOutcome =
choice === 'never_applied' ? 'dismissed' : choice choice === 'never_applied' ? 'dismissed' : choice
try { try {
const updated = await sessionSuggestedFixesApi.patchOutcome( const updated = await sessionSuggestedFixesApi.patchOutcome(
activeChatId, stored.fixId, outcomeToSend, activeChatId, stored.fixId, outcomeToSend, notes,
) )
setActiveFix(updated) setActiveFix(updated)
} catch { /* non-fatal — engineer can still escalate */ } setEscalateIntercept(null)
setShowConclude(true) setShowConclude(true)
} catch (err) {
// applied_partial without notes (or any other 4xx) must surface — the
// previous silent catch let engineers believe the partial outcome was
// recorded while it was rejected server-side.
const message = choice === 'applied_partial'
? 'Couldnt record the partial outcome. Add a short note and try again.'
: 'Couldnt record the outcome before escalating. Try again.'
toast.error(message)
// Keep the intercept open so the engineer can retry (partial path can
// re-enter the notes step from the dialog).
if (import.meta.env.DEV) console.warn('[AssistantChat] intercept outcome failed:', err)
}
}, [activeChatId, escalateIntercept]) }, [activeChatId, escalateIntercept])
// Phase 8: Resolve click — auto-mark applied_success if in verifying state // Phase 8: Resolve click — auto-mark applied_success if in verifying state
@@ -1629,7 +1643,12 @@ export default function AssistantChatPage() {
so both scroll positions and state are preserved across tab switches. */} so both scroll positions and state are preserved across tab switches. */}
{showTabStrip && activeFix && activeChatId && ( {showTabStrip && activeFix && activeChatId && (
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}> <div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
{/* key={activeFix.id} forces a fresh mount when the active
fix changes within the same pilot session — otherwise
stale messages / editorBuffer / latestScript from the
prior fix could submit against the new fix.id. */}
<ScriptBuilderTab <ScriptBuilderTab
key={activeFix.id}
fix={activeFix} fix={activeFix}
pilotSessionId={activeChatId} pilotSessionId={activeChatId}
onProgressChange={setScriptBuilderHasProgress} onProgressChange={setScriptBuilderHasProgress}