Files
kandev/apps/backend/internal/workflow/models/models.go
T
João Salavisa 57adf1449c feat(review): native code-review agent with inline findings in the Changes panel (#1957)
* feat(review): persist and publish native code-review findings

Adds the backend core for reviewing a task's working diff before a PR
exists: task-scoped review runs and anchored, advisory findings that the
Changes/Review panel can render as inline comments.

- task_review_runs / task_review_findings schema, models, and repository,
  with supersede-on-re-review and a restart sweep that cancels in-flight
  runs rather than leaving them looking alive.
- ReviewService as the single write path, publishing bus events that the
  WS gateway forwards to the client.
- internal/review: reviewer-identity resolution (agent profile > code-review
  utility agent > user default, failing closed on passthrough-only profiles),
  change-set collection, prompt batching, response parsing, anchor
  extraction, and run orchestration.
- publish_review_findings_kandev MCP tool plus the task.review.* WS actions.
- Go djb2 that matches apps/web/lib/utils/hash.ts, asserted from shared
  fixtures on both sides so stale detection agrees across the wire.

Spec: docs/specs/native-code-review/spec.md
Plan: docs/plans/native-code-review/plan.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(review): add run_code_review workflow step and wire the review runner

Makes a review pass triggerable both ways and actually runnable end to end.

- run_code_review on_enter action across models, engine compile, and an
  orchestrator callback that logs rather than propagates failures, so a task
  never gets stuck in a step because no reviewer was configured.
- The step's optional agent_profile_id is what lets a step review with a
  different model than it implemented with; export/import swap it for a
  portable agent name/model/mode so it survives a cross-workspace round trip
  instead of carrying a dangling id.
- Concrete adapters for reviewer identity, changed-file collection, prompt
  rendering from the code-review utility agent's editable prompt, and
  inference (session-bound agentctl, falling back to the host instance so a
  review works on a task whose agent is not running).
- Boot sweep cancels review runs interrupted by a restart; the E2E reset
  clears review state before deleting the tasks that own it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): add review findings state, helpers, and live updates

Frontend plumbing for native code review, ahead of the UI that renders it.

- Review types, WS message map, and an API client that preserves the backend's
  machine-readable failure code so the surface can show an actionable message.
- A review store slice that merges findings by id (a re-publish refreshes an
  issue rather than listing it twice) and keeps a bounded run history.
- Findings helpers: composite file keys matching reviewFileKey, staleness via
  the same per-file diff hash that drives review marks, and best-effort
  re-anchoring from the stored anchor text so a moved finding follows the code
  it was written about instead of pointing at whatever now occupies its line.
- Hooks for backfill, resolve/dismiss/reopen with optimistic rollback, and
  sending a finding to the agent as follow-up context without changing its
  status — handing a finding to an agent is not the human accepting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): render review findings inline in the Changes diff

Findings now appear where the code is, in the same annotation surface as
anchored review comments, with the run control that produces them.

- ReviewFindingCard: severity chip, category, Markdown body, and the three
  advisory actions. A suggestion is labelled "not applied automatically"
  because it is display-only in this iteration. Resolved and dismissed
  findings collapse to one muted line with an Undo, so a reviewed diff does
  not stay cluttered while the human can still change their mind.
- A review-finding annotation type threaded through the diff viewer, anchored
  from the same per-file diff hash the backend stamped. A finding whose diff
  moved is re-anchored from its stored text when that text survives, and
  otherwise reported as unanchored rather than placed against unrelated code.
- Review changes control with idle / running / cancel / failed states, and an
  inline Settings link for the one failure the user can actually fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): make the review control reachable from the Review toolbar

Mounts the Review changes control next to the walkthrough action and lets an
inline finding wire its own actions.

The finding card container now reads the active task and session from the store
instead of taking four callbacks threaded through FileDiffViewer and DiffViewer;
an inline finding annotation only exists when the active task has findings, so
the store is always present and every other diff consumer is spared the
plumbing. Staleness likewise hashes the diff the viewer already holds, which is
already normalized, so it matches the hash the backend stamped without passing
it down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(web): surface stale review findings above their file's diff

A finding whose diff moved and whose anchor text is gone had nowhere to render,
so it was silently dropped. It now appears in a collapsed per-file banner above
the diff: the finding may still be valid, and placing it at its stored line
would point at whatever code now occupies that line instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(review): mark the landed native code-review plan tasks done

Tasks 01-09 (backend persistence through inline diff rendering) are
implemented, tested, and committed. Tasks 10-13 — findings overview and step
editor, mobile parity, E2E, and public docs — remain pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(review): cover the on-demand review path end to end

Adds the mock-agent review responder and a Playwright spec, so both the happy
path and the no-reviewer path are verified against a real backend.

The mock agent answers any prompt carrying the review sentinel with fenced JSON,
deriving its anchors from the prompt's own diff rather than hardcoding them, so
findings land on files and lines the fixture really changed. It includes one
deliberately malformed entry to exercise the rejected-entry path.

Writing it caught an off-by-one shared by three diff walkers: splitting on "\n"
leaves an empty element after a trailing newline, which carries no diff marker
and was being counted as a context line, pushing new-side numbering one past the
end of a hunk. Interior blank lines still count, since a whitespace-stripped
blank context line legitimately appears as "".

Also adds a listInferenceAgents helper: default_utility_agent_id needs the
registered agent-type id, and an agent row UUID is rejected as not
inference-capable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(review): close review-run races and stale client findings

Addresses the three blockers and three suggestions from the PR #1957 automated
reviews. Two independent reviewers found each blocker separately.

- Launch claimed the task slot only after inserting the run row, so two
  concurrent launches both created a run and the loser returned a pending row
  with no goroutine behind it — a review that never finished until the next
  restart. The slot is now claimed before any DB write, and freed on every
  early return.
- Cancelling only marked the row. The goroutine ran on the shared background
  context, finished inference, and its completion overwrote the cancelled
  status and published findings the user had declined. Runs now carry a cancel
  handle, the WS action routes through the runner so the live context is
  cancelled, CompleteRun/FailRun refuse to resurrect a terminal run, and a
  cancelled pass neither publishes nor re-reports itself as failed.
- The backend deletes a superseded finding and inserts a replacement with a new
  id, but the publish event carried only the new one, so every open browser
  showed both at the same anchor until a reload. The event now names the
  superseded ids and the slice drops them.
- Run() waited on the shared WaitGroup, blocking on every other task's review;
  it now waits on its own pass only.
- createReviewSlice is typed over the `set` it actually uses, so call sites no
  longer pass superfluous arguments (CodeQL js/superfluous-trailing-arguments).
- A GetAgentProfile fault no longer collapses into "profile not found", which
  would have told the user to configure an agent they already have.

The cancel bug exposed a hole in the original tests: they asserted cancel could
not override a terminal run, but never the reverse. Both directions are covered
now, along with the concurrent-launch race, per-run waiting, and the supersede
path on both sides of the wire. Test fakes now mirror the service's terminal
guard rather than hiding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:05:56 +01:00

344 lines
15 KiB
Go

package models
import (
"maps"
"time"
)
// OnEnterActionType represents the type of action to execute when entering a step.
type OnEnterActionType string
const (
OnEnterEnablePlanMode OnEnterActionType = "enable_plan_mode"
OnEnterAutoStartAgent OnEnterActionType = "auto_start_agent"
OnEnterResetAgentContext OnEnterActionType = "reset_agent_context"
// OnEnterSetSessionMode declares the agent's session permission mode (e.g.
// "default", "acceptEdits") for a step on entry. The target mode is carried
// in the action Config under the "mode" key. See issue #1183.
OnEnterSetSessionMode OnEnterActionType = "set_session_mode"
// Phase 2 (ADR-0004) — generic actions are also permitted on on_enter
// so review/approval steps can clear decisions and fan out runs to
// participants when the task arrives at the step.
OnEnterClearDecisions OnEnterActionType = "clear_decisions"
OnEnterQueueRunForEachParticipant OnEnterActionType = "queue_run_for_each_participant"
OnEnterQueueRun OnEnterActionType = "queue_run"
// OnEnterRunCodeReview starts a native code-review pass over the task's
// changed files when it enters the step, so a review can sit between an
// implement step and a human gate. The optional "agent_profile_id" config
// key selects which profile's agent and model does the reviewing, which is
// how a different model can review than implemented. A failed review does
// not block the transition.
OnEnterRunCodeReview OnEnterActionType = "run_code_review"
)
// ReviewAgentProfileConfigKey is the on_enter action config key naming the
// agent profile that should perform a run_code_review pass.
const ReviewAgentProfileConfigKey = "agent_profile_id"
// OnTurnStartActionType represents the type of action to execute when a user sends a message.
type OnTurnStartActionType string
const (
OnTurnStartMoveToNext OnTurnStartActionType = "move_to_next"
OnTurnStartMoveToPrevious OnTurnStartActionType = "move_to_previous"
OnTurnStartMoveToStep OnTurnStartActionType = "move_to_step"
)
// OnTurnStartAction represents an action to execute when a user sends a message.
type OnTurnStartAction struct {
Type OnTurnStartActionType `json:"type" yaml:"type"`
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
}
// OnTurnCompleteActionType represents the type of action to execute when an agent turn completes.
type OnTurnCompleteActionType string
const (
OnTurnCompleteMoveToNext OnTurnCompleteActionType = "move_to_next"
OnTurnCompleteMoveToPrevious OnTurnCompleteActionType = "move_to_previous"
OnTurnCompleteMoveToStep OnTurnCompleteActionType = "move_to_step"
OnTurnCompleteDisablePlanMode OnTurnCompleteActionType = "disable_plan_mode"
)
// OnEnterAction represents an action to execute when entering a step.
type OnEnterAction struct {
Type OnEnterActionType `json:"type" yaml:"type"`
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
}
// OnTurnCompleteAction represents an action to execute when an agent turn completes.
type OnTurnCompleteAction struct {
Type OnTurnCompleteActionType `json:"type" yaml:"type"`
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
}
// OnExitActionType represents the type of action to execute when leaving a step.
type OnExitActionType string
const (
OnExitDisablePlanMode OnExitActionType = "disable_plan_mode"
)
// OnExitAction represents an action to execute when leaving a step.
type OnExitAction struct {
Type OnExitActionType `json:"type" yaml:"type"`
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
}
// GenericActionType represents the type of a Phase 2 (ADR-0004) action that
// can appear under any of the new event-driven triggers (on_comment,
// on_blocker_resolved, on_children_completed, on_approval_resolved,
// on_heartbeat, on_budget_alert, on_agent_error). Actions are compiled into
// the engine's typed Action structs by engine.CompileStep.
type GenericActionType string
const (
// GenericActionMoveToNext transitions to the next workflow step.
GenericActionMoveToNext GenericActionType = "move_to_next"
// GenericActionMoveToPrevious transitions to the previous workflow step.
GenericActionMoveToPrevious GenericActionType = "move_to_previous"
// GenericActionMoveToStep transitions to a configured workflow step.
GenericActionMoveToStep GenericActionType = "move_to_step"
// GenericActionAutoStartAgent starts the step's agent.
GenericActionAutoStartAgent GenericActionType = "auto_start_agent"
// GenericActionQueueRun queues a run on a target task/agent.
GenericActionQueueRun GenericActionType = "queue_run"
// GenericActionClearDecisions clears recorded decisions for the
// (task, step) pair. Typically used by a Review step's on_enter to
// start fresh after a rejection round.
GenericActionClearDecisions GenericActionType = "clear_decisions"
// GenericActionQueueRunForEachParticipant fans out queue_run over
// every participant of the step matching a configured role.
GenericActionQueueRunForEachParticipant GenericActionType = "queue_run_for_each_participant"
)
// GenericAction is the persisted shape of a Phase 2 action used in the
// new event-driven triggers. Config carries the action-specific parameters
// (target, task_id, reason, payload, role, …) interpreted by
// engine.CompileStep.
type GenericAction struct {
Type GenericActionType `json:"type" yaml:"type"`
Config map[string]interface{} `json:"config,omitempty" yaml:"config,omitempty"`
}
// StepEvents contains event-driven actions for a workflow step.
//
// The four kanban-era triggers (OnEnter, OnTurnStart, OnTurnComplete, OnExit)
// keep their typed action slices for backwards compatibility. The Phase 2
// (ADR-0004) triggers all use GenericAction so new action kinds can be added
// without further model changes — engine.CompileStep is the single point of
// translation into typed engine.Action structs.
type StepEvents struct {
OnEnter []OnEnterAction `json:"on_enter,omitempty" yaml:"on_enter,omitempty"`
OnTurnStart []OnTurnStartAction `json:"on_turn_start,omitempty" yaml:"on_turn_start,omitempty"`
OnTurnComplete []OnTurnCompleteAction `json:"on_turn_complete,omitempty" yaml:"on_turn_complete,omitempty"`
OnExit []OnExitAction `json:"on_exit,omitempty" yaml:"on_exit,omitempty"`
// Phase 2 (ADR-0004) — new event-driven triggers. Empty slices keep
// today's kanban behaviour; the engine simply finds no actions for the
// trigger and exits.
OnComment []GenericAction `json:"on_comment,omitempty" yaml:"on_comment,omitempty"`
OnBlockerResolved []GenericAction `json:"on_blocker_resolved,omitempty" yaml:"on_blocker_resolved,omitempty"`
OnChildrenCompleted []GenericAction `json:"on_children_completed,omitempty" yaml:"on_children_completed,omitempty"`
OnApprovalResolved []GenericAction `json:"on_approval_resolved,omitempty" yaml:"on_approval_resolved,omitempty"`
OnHeartbeat []GenericAction `json:"on_heartbeat,omitempty" yaml:"on_heartbeat,omitempty"`
OnBudgetAlert []GenericAction `json:"on_budget_alert,omitempty" yaml:"on_budget_alert,omitempty"`
OnAgentError []GenericAction `json:"on_agent_error,omitempty" yaml:"on_agent_error,omitempty"`
}
// ReviewStatus represents the review state of a session
type ReviewStatus string
const (
ReviewStatusPending ReviewStatus = "pending"
ReviewStatusChangesRequested ReviewStatus = "changes_requested"
ReviewStatusApproved ReviewStatus = "approved"
)
// WorkflowTemplate represents a pre-defined workflow type that workflows can adopt
type WorkflowTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsSystem bool `json:"is_system"`
// Hidden marks templates that produce hidden workflow instances
// (excluded from management UI and pickers). Used by system-only flows.
// Not persisted to DB; sourced from the embedded YAML at load time.
Hidden bool `json:"hidden,omitempty"`
Steps []StepDefinition `json:"steps"` // JSON stored
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate)
type StepDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Color string `json:"color"`
Prompt string `json:"prompt,omitempty"`
Events StepEvents `json:"events"`
AllowManualMove bool `json:"allow_manual_move"`
IsStartStep bool `json:"is_start_step"`
ShowInCommandPanel bool `json:"show_in_command_panel"`
AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
WIPLimit int `json:"wip_limit,omitempty" yaml:"wip_limit,omitempty"`
PullFromStepID string `json:"pull_from_step_id,omitempty" yaml:"pull_from_step_id,omitempty"`
// StageType mirrors WorkflowStep.StageType for templates so the office
// default + coordination workflows can declare their UX role
// ("work", "review", "approval", "custom") in YAML.
StageType StageType `json:"stage_type,omitempty"`
// AutoAdvanceRequiresSignal gates on_turn_complete transitions on an
// explicit `step_complete_kandev` MCP signal from the agent (ADR 0015).
AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal,omitempty" yaml:"auto_advance_requires_signal,omitempty"`
}
// WorkflowStep represents a step in a workflow
type WorkflowStep struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
Name string `json:"name"`
Position int `json:"position"`
Color string `json:"color"`
Prompt string `json:"prompt,omitempty"`
Events StepEvents `json:"events"`
AllowManualMove bool `json:"allow_manual_move"`
IsStartStep bool `json:"is_start_step"`
ShowInCommandPanel bool `json:"show_in_command_panel"`
AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
WIPLimit int `json:"wip_limit,omitempty"`
PullFromStepID string `json:"pull_from_step_id,omitempty"`
// StageType is a Phase 2 (ADR-0004) semantic hint for the frontend
// ("work", "review", "approval", "custom"). The engine does not branch
// on it. Stored as TEXT in workflow_steps.stage_type, defaulting to
// "custom" so existing rows remain unchanged.
StageType StageType `json:"stage_type,omitempty"`
// AutoAdvanceRequiresSignal gates on_turn_complete transitions on an
// explicit `step_complete_kandev` MCP signal from the agent (ADR 0015).
// When true, bare turn-end does NOT trigger the step's transition
// actions; instead the orchestrator waits for the agent (or a manual
// UI fallback) to write the pending-signal bag on TaskSession.Metadata.
AutoAdvanceRequiresSignal bool `json:"auto_advance_requires_signal"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// HasOnEnterAction checks if the step has a specific on_enter action type.
func (s *WorkflowStep) HasOnEnterAction(actionType OnEnterActionType) bool {
for _, action := range s.Events.OnEnter {
if action.Type == actionType {
return true
}
}
return false
}
// HasOnTurnStartAction checks if the step has any on_turn_start actions.
func (s *WorkflowStep) HasOnTurnStartAction() bool {
return len(s.Events.OnTurnStart) > 0
}
// HasOnTurnCompleteAction checks if the step has a specific on_turn_complete action type.
func (s *WorkflowStep) HasOnTurnCompleteAction(actionType OnTurnCompleteActionType) bool {
for _, action := range s.Events.OnTurnComplete {
if action.Type == actionType {
return true
}
}
return false
}
// RemapStepID returns the mapped workflow-step ID when id references a template
// step alias; otherwise it returns id unchanged.
func RemapStepID(id string, idMap map[string]string) string {
if id == "" {
return ""
}
if mapped, ok := idMap[id]; ok {
return mapped
}
return id
}
// StepTransitionTrigger represents how a session moved between steps
type StepTransitionTrigger string
const (
StepTransitionTriggerManual StepTransitionTrigger = "manual"
StepTransitionTriggerAutoComplete StepTransitionTrigger = "auto_complete"
StepTransitionTriggerApproval StepTransitionTrigger = "approval"
)
// SessionStepHistory represents an audit trail entry for session step transitions
type SessionStepHistory struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
FromStepID *string `json:"from_step_id,omitempty"`
ToStepID string `json:"to_step_id"`
Trigger StepTransitionTrigger `json:"trigger"`
ActorID *string `json:"actor_id,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// RemapStepEvents returns a copy of events with all step_id references
// in move_to_step actions replaced using the provided ID mapping.
func RemapStepEvents(events StepEvents, idMap map[string]string) StepEvents {
result := StepEvents{}
result.OnEnter = append(result.OnEnter, events.OnEnter...)
for _, a := range events.OnTurnStart {
if a.Type == OnTurnStartMoveToStep {
a.Config = remapActionStepID(a.Config, idMap)
}
result.OnTurnStart = append(result.OnTurnStart, a)
}
for _, a := range events.OnTurnComplete {
if a.Type == OnTurnCompleteMoveToStep {
a.Config = remapActionStepID(a.Config, idMap)
}
result.OnTurnComplete = append(result.OnTurnComplete, a)
}
result.OnExit = append(result.OnExit, events.OnExit...)
result.OnComment = remapGenericStepEvents(events.OnComment, idMap)
result.OnBlockerResolved = remapGenericStepEvents(events.OnBlockerResolved, idMap)
result.OnChildrenCompleted = remapGenericStepEvents(events.OnChildrenCompleted, idMap)
result.OnApprovalResolved = remapGenericStepEvents(events.OnApprovalResolved, idMap)
result.OnHeartbeat = remapGenericStepEvents(events.OnHeartbeat, idMap)
result.OnBudgetAlert = remapGenericStepEvents(events.OnBudgetAlert, idMap)
result.OnAgentError = remapGenericStepEvents(events.OnAgentError, idMap)
return result
}
func remapGenericStepEvents(actions []GenericAction, idMap map[string]string) []GenericAction {
result := make([]GenericAction, 0, len(actions))
for _, action := range actions {
if action.Type == GenericActionMoveToStep {
action.Config = remapActionStepID(action.Config, idMap)
}
result = append(result, action)
}
return result
}
func remapActionStepID(config map[string]any, idMap map[string]string) map[string]any {
if config == nil {
return nil
}
stepID, ok := config["step_id"].(string)
if !ok {
return config
}
newID, found := idMap[stepID]
if !found {
return config
}
cfg := make(map[string]any, len(config))
maps.Copy(cfg, config)
cfg["step_id"] = newID
return cfg
}