Files
resolutionflow/backend/app/schemas/session_suggested_fix.py
Michael Chihlas 00663a4734
Some checks failed
Mirror to GitHub / mirror (push) Has been cancelled
CI / backend (pull_request) Successful in 10m43s
CI / frontend (pull_request) Successful in 5m42s
CI / e2e (pull_request) Successful in 11m13s
feat(suggested-fix): add applied_pending status for deferred verification
Engineer applies a fix but can't verify yet (waiting on client power-cycle,
AD replication, async sync). Today the verifying banner forces a synchronous
verdict (worked / didn't / partial) — anything else means leaving the banner
stale or guessing wrong. This adds a fourth outcome that parks the fix in a
non-terminal "Awaiting verification" state with a reason ("waiting on what?")
and exposes it on the chat-anchored banner so the engineer doesn't lose track.

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 17:32:37 -04:00

177 lines
6.9 KiB
Python

"""Pydantic schemas for session suggested fixes (Phase 3).
See FLOWPILOT-MIGRATION.md Section 5.2.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field
UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"]
# "dismissed" here is the outcome dimension — orthogonal to UserDecision's
# "dismissed" (script-path choice), though the migration backfill aligns
# them for pre-existing rows.
FixStatus = Literal[
"proposed",
"applied_success",
"applied_failed",
"applied_partial",
"applied_pending",
"dismissed",
]
class SessionSuggestedFixResponse(BaseModel):
id: UUID
session_id: UUID
title: str
description: str
confidence_pct: int
script_template_id: UUID | None
ai_drafted_script: str | None
ai_drafted_parameters: dict[str, Any] | None
user_decision: UserDecision | None
superseded_at: datetime | None
created_at: datetime
status: FixStatus
applied_at: datetime | None
verified_at: datetime | None
partial_notes: str | None
pending_reason: str | None
failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None
model_config = {"from_attributes": True}
class SessionSuggestedFixDecisionRequest(BaseModel):
"""Engineer's path choice on a suggested fix.
Server-side side effects per Section 5.2:
- one_off: record decision, return the rendered (AI-drafted or
engineer-edited) script. No persistent library artifact created.
- draft_template: same as one_off, plus TemplateExtractionService
proposes a parameterization and a draft_templates row is created.
- build_template: return a redirect payload pointing at the Script
Builder page, pre-loaded with the drafted script body.
- dismissed: mark the fix superseded.
For one_off / draft_template, the engineer may have edited the drafted
script or its parameters in the dialog. The final versions are sent
back here so we persist what will actually run.
"""
decision: UserDecision
# Present for one_off / draft_template — the engineer's final version of
# the drafted script after any inline edits. Omit to use the fix's
# `ai_drafted_script` verbatim.
edited_script: str | None = Field(None, min_length=1, max_length=50_000)
# Parameter values used when rendering (informational, stored on the
# draft_template row so a reviewer can see what the first run used).
parameters_used: dict[str, Any] | None = None
class SessionSuggestedFixDecisionResponse(BaseModel):
"""Returned after recording a decision."""
id: UUID
user_decision: UserDecision
# Populated for one_off / draft_template — the script to display/run.
rendered_script: str | None = None
# Populated for draft_template — the ID of the draft_templates row so
# the post-resolve TemplatizePrompt can fetch it in Phase 6.
draft_template_id: UUID | None = None
# Populated for build_template — where to send the engineer next.
redirect_path: str | None = Field(
None,
description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)",
)
# Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[
"applied_success",
"applied_failed",
"applied_partial",
"applied_pending",
"dismissed",
]
class SessionSuggestedFixOutcomeRequest(BaseModel):
"""Engineer-reported outcome of applying a suggested fix.
Writes to session_suggested_fixes.status and companion columns. This is
orthogonal to `user_decision` (which records which script-path the
engineer took); outcome captures whether the fix actually worked.
Allowed transitions:
- from `proposed`, `applied_partial`, or `applied_pending`: any outcome
is valid. Partial means "did some of it"; pending means "did all of
it but verification is deferred (waiting on client, async sync, etc)".
Both are parked, not terminal — the engineer may advance them to
success/failed/dismiss.
- from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409
"""
outcome: FixOutcome
# Required for applied_partial AND applied_pending; optional for
# applied_failed; ignored otherwise. For pending, this is the
# "what are you waiting on?" reason (e.g. "client power-cycling router").
notes: str | None = Field(None, max_length=500)
class SessionSuggestedFixScriptRequest(BaseModel):
"""Engineer-submitted drafted script for a suggested fix.
Called when the inline Script Builder tab's Submit action fires. The
fix must be non-terminal (still proposed/applied_partial). Setting
the script does NOT stamp applied_at — a draft is not an application.
"""
ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
ai_drafted_parameters: dict[str, Any] | None = None
# ── Resolution note preview ────────────────────────────────────────────────
class ResolutionNotePreviewResponse(BaseModel):
markdown: str
target_ticket_ref: str | None
state_version: int
from_cache: bool
# ── Phase 4: Resolve + Escalate post ───────────────────────────────────────
class ResolutionNotePostRequest(BaseModel):
"""Engineer-edited resolution markdown. Server posts to PSA + marks resolved."""
markdown: str = Field(..., min_length=1, max_length=20_000)
# Optional override for resolution summary shown on the session listing;
# defaults to the first line of the markdown if omitted.
resolution_summary: str | None = Field(None, max_length=500)
class EscalationPackagePostRequest(BaseModel):
markdown: str = Field(..., min_length=1, max_length=20_000)
# Free-text reason shown in session listings and escalation queue.
escalation_reason: str | None = Field(None, max_length=500)
class ResolutionPostResponse(BaseModel):
"""Response shape for both Resolve/Escalate POST endpoints."""
# "resolved" / "escalated" / "resolved_local" / "escalated_local"
# The _local variants indicate the session has no linked PSA ticket —
# markdown is stored, session state is updated, nothing was posted externally.
outcome: str
session_status: str
external_id: str | None = None
posted_at: datetime | None = None
# Populated when a status transition was attempted and verified. None
# when no target status is configured in account_settings.preferences.
verified_status_id: int | None = None
verified_status_name: str | None = None
status_transition_skipped_reason: str | None = None