Files
kandev/apps/backend/internal/task/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

1617 lines
67 KiB
Go

package models
import (
"encoding/json"
"errors"
"maps"
"strings"
"time"
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/sysprompt"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// ErrExecutorRunningNotFound is returned when no executor running record exists for a session.
var ErrExecutorRunningNotFound = errors.New("executor running not found")
// ErrTaskSessionNotFound is returned when no task session record exists.
var ErrTaskSessionNotFound = errors.New("task session not found")
// ErrTaskWalkthroughNotFound is returned when no walkthrough record exists.
var ErrTaskWalkthroughNotFound = errors.New("task walkthrough not found")
// ErrTaskReviewRunNotFound is returned when no code-review run record exists.
var ErrTaskReviewRunNotFound = errors.New("task review run not found")
// ErrTaskReviewFindingNotFound is returned when no code-review finding record exists.
var ErrTaskReviewFindingNotFound = errors.New("task review finding not found")
// ErrExecutorNotFound is returned by the executor repository when no
// executor row exists for the given ID. Callers should use errors.Is to
// distinguish "row doesn't exist" (404 semantically) from transport-level
// failures (storage outage, timeout, ctx cancel) that happen to also
// surface from the same lookup.
var ErrExecutorNotFound = errors.New("executor not found")
// ErrExecutionRotated is returned by CAS-style updates on executors_running when the
// row's agent_execution_id has rotated since the caller observed it. Indicates the
// caller's write target a now-defunct execution and should be discarded — typically
// a stale resume-token event from an execution that was replaced (model switch,
// context reset, fresh re-launch). Callers should not retry; the new execution
// will produce its own events.
var ErrExecutionRotated = errors.New("execution rotated; CAS write rejected")
// Status values for executors_running.status. The lifecycle manager mirrors
// active execution state into this column; the orchestrator flips a row to
// "prepared" when a prepare-only launch finishes with the agent process
// intentionally not started.
const (
ExecutorRunningStatusStarting = "starting"
ExecutorRunningStatusRunning = "running"
ExecutorRunningStatusReady = "ready"
ExecutorRunningStatusFailed = "failed"
ExecutorRunningStatusStopped = "stopped"
ExecutorRunningStatusComplete = "completed"
ExecutorRunningStatusPrepared = "prepared"
)
// ListMessagesOptions defines pagination options for listing messages
type ListMessagesOptions struct {
Limit int
Before string
After string
Sort string
}
// SearchMessagesOptions defines options for searching a session's messages.
type SearchMessagesOptions struct {
Query string
Limit int
}
// PluginMessageFilter defines the filters for the plugin Host data API's
// message reader (ADR 0047). SessionIDs and TaskIDs narrow by session/task
// (ORed within each, ANDed across the two — a message must match any
// requested session AND any requested task when both are set); empty means
// unconstrained on that axis. Since/Until bound created_at (Since inclusive,
// Until exclusive); nil means unbounded. Types narrows by message type; empty
// means all types. Results are ordered oldest-first by (created_at, id) with
// SQL Limit/Offset pagination.
type PluginMessageFilter struct {
SessionIDs []string
TaskIDs []string
Types []string
Since *time.Time
Until *time.Time
Limit int
Offset int
}
// Task metadata keys used for deferred agent start (e.g., task.moved → handleTaskMovedNoSession).
const (
MetaKeyAgentProfileID = "agent_profile_id"
MetaKeyExecutorID = "executor_id"
MetaKeyExecutorProfileID = "executor_profile_id"
// MetaKeyWorkspacePath is the optional host folder for repo-less tasks
// (set by CreateTask, read by the orchestrator when building a session).
// Centralised here so the set/read sites can't drift apart.
MetaKeyWorkspacePath = "workspace_path"
)
// TaskSession.Metadata key that records how the session came into existence.
// workflow_switch means the session profile was selected by workflow routing
// rather than direct user selection.
const (
SessionMetaKeyCreatedBy = "created_by"
SessionCreatedByWorkflowSwitch = "workflow_switch"
)
// SessionMetaKeySessionMode records the agent's last-known session permission
// mode (auto / default / accept-edits, etc.) so it survives a backend restart or
// SSR reload, alongside the in-memory re-apply on context reset. See issue #1183.
const SessionMetaKeySessionMode = "session_mode"
// SessionMetaKeyRuntimeConfig records the provider's latest session runtime
// state (model, mode, and dynamic config options) separately from the profile
// snapshot that seeded the session.
const SessionMetaKeyRuntimeConfig = "runtime_config"
// SessionMetaKeyRuntimeConfigOverrides records explicit user selections
// separately from provider snapshots so delayed events cannot clobber resume
// intent. Overrides are applied after SessionMetaKeyRuntimeConfig.
const SessionMetaKeyRuntimeConfigOverrides = "runtime_config_overrides"
// SessionMetaKeyACPConfigBaseline records the write-once effective ACP select
// values with which a task session started. It is comparison metadata only;
// runtime restoration continues to use SessionMetaKeyRuntimeConfig.
const SessionMetaKeyACPConfigBaseline = "acp_config_baseline"
// SessionMetaKeyACPModelState records the provider's latest complete model
// selector state so task-detail boot hydration does not wait for WebSocket
// reconnection. It is display metadata and is not replayed to the provider.
const SessionMetaKeyACPModelState = "acp_model_state"
// TurnMetaKeyRuntimeConfigSnapshot stores the immutable effective runtime
// configuration attributed to one prompt/response turn.
const TurnMetaKeyRuntimeConfigSnapshot = "runtime_config_snapshot"
// SessionRuntimeConfig is persisted as provider state or explicit overrides.
// On resume, explicit values take precedence over the latest provider snapshot
// so delayed provider events cannot replace user intent.
type SessionRuntimeConfig struct {
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
ConfigOptions map[string]string `json:"config_options,omitempty"`
}
// TurnRuntimeConfigSnapshot is the minimal display state captured when a turn
// starts. It intentionally excludes the provider's complete option catalog.
type TurnRuntimeConfigSnapshot struct {
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
ConfigOptions []TurnRuntimeConfigOption `json:"config_options,omitempty"`
ConfigBaseline map[string]string `json:"config_baseline,omitempty"`
}
// TurnRuntimeConfigOption preserves one selected value in provider order.
type TurnRuntimeConfigOption struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Value string `json:"value"`
ValueName string `json:"value_name,omitempty"`
}
// LoadTurnRuntimeConfigSnapshot decodes typed and JSON-rehydrated turn metadata.
func LoadTurnRuntimeConfigSnapshot(metadata map[string]interface{}) (TurnRuntimeConfigSnapshot, bool) {
if metadata == nil {
return TurnRuntimeConfigSnapshot{}, false
}
raw := metadata[TurnMetaKeyRuntimeConfigSnapshot]
if snapshot, ok := raw.(TurnRuntimeConfigSnapshot); ok {
return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0
}
data, err := json.Marshal(raw)
if err != nil {
return TurnRuntimeConfigSnapshot{}, false
}
var snapshot TurnRuntimeConfigSnapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return TurnRuntimeConfigSnapshot{}, false
}
return snapshot, snapshot.Model != "" || snapshot.Mode != "" || len(snapshot.ConfigOptions) > 0
}
// SessionMetaKeyPendingStepCompletion stores the agent's (or manual fallback's)
// step-complete signal under TaskSession.Metadata. ADR 0015: the orchestrator
// reads this on turn-end for steps with AutoAdvanceRequiresSignal=true and
// only fires on_turn_complete transitions when a matching signal is present.
// Cleared on successful transition, on user reply before transition, or any
// other step change.
const SessionMetaKeyPendingStepCompletion = "pending_step_completion_signal"
// SessionMetaKeyLastAgentError stores the last recoverable agent runtime
// failure for UI surfaces that need to keep the error visible after auto-resume.
const SessionMetaKeyLastAgentError = "last_agent_error"
// LastAgentError is persisted under TaskSession.Metadata[SessionMetaKeyLastAgentError].
type LastAgentError struct {
Message string `json:"message"`
OccurredAt time.Time `json:"occurred_at"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
}
func LoadLastAgentError(metadata map[string]interface{}) (LastAgentError, bool) {
if metadata == nil {
return LastAgentError{}, false
}
raw, ok := metadata[SessionMetaKeyLastAgentError]
if !ok || raw == nil {
return LastAgentError{}, false
}
var out LastAgentError
if err := mapToLastAgentError(raw, &out); err != nil || out.Message == "" {
return LastAgentError{}, false
}
return out, true
}
func mapToLastAgentError(raw interface{}, out *LastAgentError) error {
data, err := json.Marshal(raw)
if err != nil {
return err
}
return json.Unmarshal(data, out)
}
func (e LastAgentError) Stamp() string {
return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message
}
func (e LastAgentError) MatchesStamp(stamp string) bool {
if stamp == e.Stamp() {
return true
}
suffix := ":" + e.Message
if !strings.HasSuffix(stamp, suffix) {
return false
}
rawOccurredAt := strings.TrimSuffix(stamp, suffix)
if rawOccurredAt == "" {
return e.OccurredAt.IsZero()
}
occurredAt, err := time.Parse(time.RFC3339Nano, rawOccurredAt)
if err != nil {
return false
}
return occurredAt.Equal(e.OccurredAt)
}
func (e LastAgentError) IsDismissed() bool {
return e.DismissedAt != nil && !e.DismissedAt.IsZero()
}
// PendingStepCompletionSignal source values.
const (
StepCompletionSourceAgent = "agent"
StepCompletionSourceManualFallback = "manual_fallback"
)
// PendingStepCompletionSignal is the JSON shape persisted under
// TaskSession.Metadata[SessionMetaKeyPendingStepCompletion]. It records an
// agent-emitted (or user-emitted via the fallback button) completion signal
// that the orchestrator should consume to drive a workflow step transition.
// See ADR 0015 for the lifecycle (set → read → clear).
type PendingStepCompletionSignal struct {
StepID string `json:"step_id"`
Source string `json:"source"`
Summary string `json:"summary"`
Handoff string `json:"handoff,omitempty"`
Blockers string `json:"blockers,omitempty"`
SignaledAt time.Time `json:"signaled_at"`
}
// LoadSessionRuntimeConfig decodes the runtime-config bag entry from session
// metadata. It tolerates both typed values and JSON-rehydrated maps.
func LoadSessionRuntimeConfig(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
return loadSessionRuntimeConfig(metadata, SessionMetaKeyRuntimeConfig)
}
// LoadSessionRuntimeConfigOverrides decodes explicit user runtime selections.
func LoadSessionRuntimeConfigOverrides(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
return loadSessionRuntimeConfig(metadata, SessionMetaKeyRuntimeConfigOverrides)
}
func loadSessionRuntimeConfig(metadata map[string]interface{}, key string) (SessionRuntimeConfig, bool) {
if metadata == nil {
return SessionRuntimeConfig{}, false
}
raw, ok := metadata[key]
if !ok || raw == nil {
return SessionRuntimeConfig{}, false
}
switch v := raw.(type) {
case SessionRuntimeConfig:
return v, !v.IsZero()
case map[string]string:
out := SessionRuntimeConfig{
Model: v["model"],
Mode: v["mode"],
ConfigOptions: maps.Clone(v),
}
delete(out.ConfigOptions, "model")
delete(out.ConfigOptions, "mode")
if len(out.ConfigOptions) == 0 {
out.ConfigOptions = nil
}
return out, !out.IsZero()
case map[string]interface{}:
out := SessionRuntimeConfig{
Model: StringFromAny(v["model"]),
Mode: StringFromAny(v["mode"]),
}
if opts := stringMapFromAny(v["config_options"]); len(opts) > 0 {
out.ConfigOptions = opts
}
return out, !out.IsZero()
default:
return SessionRuntimeConfig{}, false
}
}
// LoadSessionACPConfigBaseline decodes the write-once ACP configuration
// baseline from session metadata. It tolerates typed and JSON-rehydrated maps.
func LoadSessionACPConfigBaseline(metadata map[string]interface{}) (map[string]string, bool) {
if metadata == nil {
return nil, false
}
values := stringMapFromAnyPreservingEmpty(metadata[SessionMetaKeyACPConfigBaseline])
if len(values) == 0 {
return nil, false
}
return values, true
}
func stringMapFromAnyPreservingEmpty(raw interface{}) map[string]string {
switch values := raw.(type) {
case map[string]string:
return maps.Clone(values)
case map[string]interface{}:
out := make(map[string]string, len(values))
for key, value := range values {
if str, ok := value.(string); ok {
out[key] = str
}
}
return out
default:
return nil
}
}
// IsZero reports whether the runtime config carries any selected value.
func (c SessionRuntimeConfig) IsZero() bool {
return c.Model == "" && c.Mode == "" && len(c.ConfigOptions) == 0
}
func stringMapFromAny(raw interface{}) map[string]string {
switch v := raw.(type) {
case map[string]string:
return maps.Clone(v)
case map[string]interface{}:
out := make(map[string]string, len(v))
for key, value := range v {
if str := StringFromAny(value); str != "" {
out[key] = str
}
}
return out
default:
return nil
}
}
// LoadPendingStepSignal decodes the pending-completion bag entry from a
// session's metadata. Single source of truth shared by the MCP handler
// (write site, idempotency check) and the orchestrator (read site, gating).
// Survives both the in-process typed shape and the JSON-rehydrated
// `map[string]interface{}` shape produced when the row is loaded fresh from
// SQLite after a backend restart.
func LoadPendingStepSignal(metadata map[string]interface{}) (PendingStepCompletionSignal, bool) {
if metadata == nil {
return PendingStepCompletionSignal{}, false
}
raw, ok := metadata[SessionMetaKeyPendingStepCompletion]
if !ok || raw == nil {
return PendingStepCompletionSignal{}, false
}
switch v := raw.(type) {
case PendingStepCompletionSignal:
return v, true
case map[string]interface{}:
out := PendingStepCompletionSignal{
StepID: StringFromAny(v["step_id"]),
Source: StringFromAny(v["source"]),
Summary: StringFromAny(v["summary"]),
Handoff: StringFromAny(v["handoff"]),
Blockers: StringFromAny(v["blockers"]),
}
if ts, ok := v["signaled_at"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, ts); err == nil {
out.SignaledAt = parsed
}
}
return out, out.StepID != ""
}
return PendingStepCompletionSignal{}, false
}
// StringFromAny narrows an interface{} slot to a string, returning "" when
// the value is absent or of a different type. Used by both the
// PendingStepCompletionSignal map-shape decoder and the orchestrator's
// step-completion event-payload parser — single source of truth so the
// two decoders can't drift on what counts as a "missing string".
func StringFromAny(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// Task origin values for the Origin field.
const (
TaskOriginManual = "manual"
TaskOriginAgentCreated = "agent_created"
TaskOriginRoutine = "routine"
TaskOriginOnboarding = "onboarding"
TaskOriginAutomationRun = "automation_run"
)
// Task represents a task in the database
type Task struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority string `json:"priority"`
Position int `json:"position"` // Order within workflow step
Metadata map[string]interface{} `json:"metadata,omitempty"`
Repositories []*TaskRepository `json:"repositories,omitempty"`
WorkspaceFolders []*TaskWorkspaceFolder `json:"workspace_folders,omitempty"`
IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat
ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// ArchivedByCascadeID is set when the task was archived as part of an
// office task-handoffs cascade (phase 6). UnarchiveTaskByCascade uses
// it to scope restoration to exactly the descendants this cascade
// owned, leaving manually-archived tasks alone. Empty for
// manually-archived tasks and any task archived before phase 6.
ArchivedByCascadeID string `json:"archived_by_cascade_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Office extensions.
//
// ADR 0005 Wave F: the task's assignee is no longer a column on the
// tasks table. The canonical store is a `runner` row in
// workflow_step_participants (resolve via
// workflow.Repository.ResolveCurrentRunner). The struct field below
// is a read-time projection populated by repo SELECT queries through
// a correlated subquery and consumed unchanged by callers that
// previously read tasks.assignee_agent_profile_id. Repo writes to
// this field are routed to SetTaskRunner / ClearTaskRunner inside
// the task repository.
AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"`
Origin string `json:"origin,omitempty"` // manual, agent_created, routine
ProjectID string `json:"project_id,omitempty"` // FK to office project
Labels string `json:"labels,omitempty"` // JSON array string, default "[]"
Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42"
// IsFromOffice is a read-time projection set by the task repo's SELECT
// (see isFromOfficeProjection in repository/sqlite/task.go). True when
// the task is owned by office: either it has a non-empty ProjectID, or
// its workflow matches the workspace's office_workflow_id. Kanban-board
// tasks always come back false. UI callers gate office-only surfaces on
// this (e.g. the "Open in office view" topbar link).
IsFromOffice bool `json:"is_from_office,omitempty"`
}
// IsOfficeOwnedAndAssigned reports whether runtime behavior belongs to an
// Office-owned task with a designated runner. Kanban tasks may also project a
// runner from their workflow step, but retain normal per-session semantics.
func (t *Task) IsOfficeOwnedAndAssigned() bool {
return t != nil && t.IsFromOffice && t.AssigneeAgentProfileID != ""
}
// ChildCompletionRow is the compact active-child projection used to decide
// whether a parent task's on_children_completed trigger is ready to fire.
type ChildCompletionRow struct {
ID string `json:"id" db:"id"`
State v1.TaskState `json:"state" db:"state"`
Title string `json:"title" db:"title"`
WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"`
TerminalWorkflowStep bool `json:"terminal_workflow_step"` // computed by annotateTerminalChildSteps, not a DB column
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// IsTerminalTaskState reports whether a task state means no further child work
// is expected from that task.
func IsTerminalTaskState(state v1.TaskState) bool {
switch state {
case v1.TaskStateCompleted, v1.TaskStateFailed, v1.TaskStateCancelled:
return true
default:
return false
}
}
// TaskTreeFilters provides filter options for the task tree query.
type TaskTreeFilters struct {
ProjectID string
AssigneeID string
WorkflowID string
Origin string
}
// WorkflowStyle values are persisted in workflows.style. See ADR-0004.
// They're a UX hint for the frontend; backend code MUST NOT branch on them.
const (
WorkflowStyleKanban = "kanban"
WorkflowStyleOffice = "office"
WorkflowStyleCustom = "custom"
)
// WorkflowSource values are persisted in workflows.source and record where a
// workflow definition came from. Manual workflows are user-managed; GitHub
// workflows are owned by the workflow-sync poller and may be overwritten or
// removed on each sync.
const (
WorkflowSourceManual = "manual"
WorkflowSourceGitHub = "github"
)
// Workflow represents a task workflow
type Workflow struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
SortOrder int `json:"sort_order"`
// Source records where the workflow definition came from ("manual" or
// "github"). SourcePath is the repo-relative file path the definition
// was synced from; empty for manual workflows.
Source string `json:"source,omitempty"`
SourcePath string `json:"source_path,omitempty"`
// Hidden workflows are excluded from management and picker UIs by default.
// Used by system-only flows like Improve Kandev.
Hidden bool `json:"hidden,omitempty"`
// Style is a Phase 2 (ADR-0004) UX hint read by the frontend ONLY.
// Allowed values: "kanban" | "office" | "custom". Empty / unknown
// values fall back to "kanban" via the schema default. Backend code
// MUST NOT branch on this field — it is a presentation hint, not a
// behavioural invariant.
Style string `json:"style,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Workspace represents a workspace
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
DefaultExecutorID *string `json:"default_executor_id,omitempty"`
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Office extensions
TaskPrefix string `json:"task_prefix,omitempty"` // e.g. "KAN"
TaskSequence int `json:"task_sequence,omitempty"` // auto-incrementing counter
OfficeWorkflowID string `json:"office_workflow_id,omitempty"` // FK to system office workflow
}
// TaskRepository represents a repository associated with a task
type TaskRepository struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch,omitempty"`
Position int `json:"position"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskWorkspaceFolder is a canonical host-folder attachment owned by a task.
// It stays separate from TaskRepository because folders are not Git sources.
type TaskWorkspaceFolder struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
LocalPath string `json:"local_path"`
DisplayName string `json:"display_name"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkspaceSourceBatch identifies exactly the durable rows created by one
// attachment operation, so a later materialization failure can compensate
// without touching pre-existing sources.
type WorkspaceSourceBatch struct {
TaskID string `json:"task_id"`
Sources []WorkspaceSource `json:"sources,omitempty"`
RepositoryUpdates []WorkspaceSourceRepositoryUpdate `json:"repository_updates,omitempty"`
}
// WorkspaceSourceRepositoryUpdate records a legacy association branch derived
// during workspace-source validation. PreviousBaseBranch permits compensation
// if later runtime materialization fails.
type WorkspaceSourceRepositoryUpdate struct {
TaskRepositoryID string `json:"task_repository_id"`
BaseBranch string `json:"base_branch"`
PreviousBaseBranch string `json:"previous_base_branch"`
}
// WorkspaceSource carries exactly one durable source kind. Sources preserve
// the caller's mixed order for global position allocation.
type WorkspaceSource struct {
Repository *TaskRepository `json:"repository,omitempty"`
Folder *TaskWorkspaceFolder `json:"folder,omitempty"`
}
// MessageAuthorType represents who authored a message
type MessageAuthorType string
const (
// MessageAuthorUser indicates a message from a human user
MessageAuthorUser MessageAuthorType = "user"
// MessageAuthorAgent indicates a message from an AI agent
MessageAuthorAgent MessageAuthorType = "agent"
)
// MessageType represents the type of message content
type MessageType string
const (
// MessageTypeMessage is the default type for user/agent regular messages
MessageTypeMessage MessageType = "message"
// MessageTypeContent is for agent response content
MessageTypeContent MessageType = "content"
// MessageTypeToolCall is when agent uses a tool
MessageTypeToolCall MessageType = "tool_call"
// MessageTypeToolEdit is for file edit operations with diff visualization
MessageTypeToolEdit MessageType = "tool_edit"
// MessageTypeToolRead is for file read operations
MessageTypeToolRead MessageType = "tool_read"
// MessageTypeToolExecute is for command execution operations
MessageTypeToolExecute MessageType = "tool_execute"
// MessageTypeProgress is for progress updates
MessageTypeProgress MessageType = "progress"
// MessageTypeLog is for agent log messages (info, debug, warning, etc.)
MessageTypeLog MessageType = "log"
// MessageTypeError is for error messages
MessageTypeError MessageType = "error"
// MessageTypeStatus is for status changes: started, completed, failed
MessageTypeStatus MessageType = "status"
// MessageTypePermissionRequest is for agent permission requests
MessageTypePermissionRequest MessageType = "permission_request"
// MessageTypeClarificationRequest is for agent clarification questions
MessageTypeClarificationRequest MessageType = "clarification_request"
// MessageTypeScriptExecution is for setup/cleanup script execution messages
MessageTypeScriptExecution MessageType = "script_execution"
// MessageTypeThinking is for agent thinking/reasoning content
MessageTypeThinking MessageType = "thinking"
// MessageTypeAgentPlan is for agent native plan content (e.g. ExitPlanMode)
MessageTypeAgentPlan MessageType = "agent_plan"
// MessageTypeTodo is for agent todo/task list updates
MessageTypeTodo MessageType = "todo"
)
// PermissionStatus is the resolution status of a permission request message,
// stored under metadata.status. Pending is the absence sentinel: a freshly
// created permission_request message does not set metadata.status, and any
// reader treats that as PermissionStatusPending.
type PermissionStatus string
const (
// PermissionStatusPending is the empty-string sentinel for the unresolved
// state. CreatePermissionRequestMessage does not write metadata.status at
// all; readers compare against this constant instead of the bare "".
PermissionStatusPending PermissionStatus = ""
// PermissionStatusApproved means the user (or automation) accepted the request.
PermissionStatusApproved PermissionStatus = "approved"
// PermissionStatusRejected means the user did not accept the request.
// Collapses two distinct ACP outcomes: explicit Deny (selected option with
// kind reject_once/reject_always) AND dialog dismissal (ACP cancelled
// outcome on the wire, triggered today only by the fallback path when the
// prompt offers no reject_* option). Split into separate Rejected /
// Dismissed statuses if the audit trail ever needs to distinguish them.
PermissionStatusRejected PermissionStatus = "rejected"
// PermissionStatusExpired means the request became unanswerable: the agent
// withdrew the prompt before a response arrived (permission_cancelled
// event), or agentctl rejected the response because the pending entry was
// already gone. No ACP outcome ever reaches the wire in this state.
PermissionStatusExpired PermissionStatus = "expired"
)
// TaskPendingAction is the compact task-list projection for a session blocked
// on user input.
type TaskPendingAction string
const (
TaskPendingActionClarification TaskPendingAction = "clarification"
TaskPendingActionPermission TaskPendingAction = "permission"
)
// Message represents a message in a task session
type Message struct {
ID string `json:"id"`
TaskSessionID string `json:"session_id"`
TaskID string `json:"task_id,omitempty"`
TurnID string `json:"turn_id"` // FK to task_session_turns
AuthorType MessageAuthorType `json:"author_type"`
AuthorID string `json:"author_id,omitempty"` // User ID or Agent Execution ID
Content string `json:"content"`
Type MessageType `json:"type,omitempty"` // Defaults to "message"
Metadata map[string]interface{} `json:"metadata,omitempty"`
RequestsInput bool `json:"requests_input"` // True if agent is requesting user input
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // Authoritative per-message change signal
}
// ToAPI converts internal Message to API type.
// Only true system-injected content (wrapped in <kandev-system> tags) is stripped
// from the visible content sent to the UI.
func (m *Message) ToAPI() *v1.Message {
messageType := string(m.Type)
if messageType == "" {
messageType = string(MessageTypeMessage)
}
hasHidden := sysprompt.HasSystemContent(m.Content)
meta := ProjectMessageMetadata(m.Metadata)
if hasHidden {
if meta == nil {
meta = make(map[string]interface{})
} else {
// Copy to avoid mutating original
meta = copyMetadata(meta)
}
meta["has_hidden_prompts"] = true
}
result := &v1.Message{
ID: m.ID,
TaskSessionID: m.TaskSessionID,
TaskID: m.TaskID,
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
AuthorID: m.AuthorID,
Content: sysprompt.StripSystemContent(m.Content),
Type: messageType,
Metadata: meta,
RequestsInput: m.RequestsInput,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
if hasHidden {
result.RawContent = m.Content
}
return result
}
// copyMetadata returns a shallow copy of a metadata map.
func copyMetadata(m map[string]any) map[string]any {
cp := make(map[string]any, len(m))
maps.Copy(cp, m)
return cp
}
// Turn represents a single prompt/response cycle within a task session.
// A turn starts when a user sends a prompt and ends when the agent completes,
// cancels, or errors.
type Turn struct {
ID string `json:"id"`
TaskSessionID string `json:"session_id"`
TaskID string `json:"task_id"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ReviewStatus represents the review state of a TaskSession. The zero value
// (ReviewStatusNone, the empty string) means "no review needed" and serializes
// out via omitempty, preserving the JSON shape of the legacy *string field.
// The persisted column (task_sessions.review_status TEXT DEFAULT ”) is
// compatible with these values without any migration.
type ReviewStatus string
const (
// ReviewStatusNone is the zero value meaning no review is needed.
ReviewStatusNone ReviewStatus = ""
// ReviewStatusPending indicates the session is waiting on a human review.
ReviewStatusPending ReviewStatus = "pending"
// ReviewStatusApproved indicates the reviewer accepted the session output.
ReviewStatusApproved ReviewStatus = "approved"
)
// String returns the underlying string value for the review status.
func (r ReviewStatus) String() string { return string(r) }
// TaskSessionState represents the state of an agent session
type TaskSessionState string
const (
// TaskSessionStateCreated - session created but agent not started
TaskSessionStateCreated TaskSessionState = "CREATED"
// TaskSessionStateStarting - agent is starting up
TaskSessionStateStarting TaskSessionState = "STARTING"
// TaskSessionStateRunning - agent is actively running
TaskSessionStateRunning TaskSessionState = "RUNNING"
// TaskSessionStateIdle - office sessions only: agent process and executor torn down
// between turns; conversation (acp_session_id) preserved for the next run.
TaskSessionStateIdle TaskSessionState = "IDLE"
// TaskSessionStateWaitingForInput - agent waiting for user input
TaskSessionStateWaitingForInput TaskSessionState = "WAITING_FOR_INPUT"
// TaskSessionStateCompleted - agent finished successfully
TaskSessionStateCompleted TaskSessionState = "COMPLETED"
// TaskSessionStateFailed - agent failed with error
TaskSessionStateFailed TaskSessionState = "FAILED"
// TaskSessionStateCancelled - agent was manually stopped
TaskSessionStateCancelled TaskSessionState = "CANCELLED"
)
// TaskSessionWorktree represents the association between a task session and a worktree
type TaskSessionWorktree struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
WorktreeID string `json:"worktree_id"`
RepositoryID string `json:"repository_id"`
BranchSlug string `json:"branch_slug,omitempty"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
// Worktree details stored on this association
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
}
// SessionBranchInfo is a lightweight projection of a session with its worktree branch.
// Used by the PR watch reconciler to find sessions that may need PR watches.
type SessionBranchInfo struct {
SessionID string
TaskID string
WorkspaceID string
Branch string
}
// TaskSession represents a persistent agent execution session for a task.
// This replaces the in-memory TaskExecution tracking and survives backend restarts.
type TaskSession struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab
AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution
ContainerID string `json:"container_id"` // Docker container ID for cleanup
AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used
ExecutionProfileID string `json:"execution_profile_id"` // Concrete profile used for this execution
ExecutorID string `json:"executor_id"`
ExecutorProfileID string `json:"executor_profile_id"`
EnvironmentID string `json:"environment_id"`
RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility)
BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility)
BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff)
WorkspacePath string `json:"workspace_path"` // Optional host folder for repo-less tasks (when user picked a starting folder)
Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees
AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"`
ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"`
EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"`
RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"`
State TaskSessionState `json:"state"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
// Environment reference
TaskEnvironmentID string `json:"task_environment_id,omitempty"` // FK to task_environments for shared env
// Workflow-related fields
IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task
IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode
ReviewStatus ReviewStatus `json:"review_status,omitempty"` // zero value = no review needed
}
// ToAPI converts internal TaskSession to API type
// TODO: Add v1.TaskSession type to pkg/api/v1/
func (s *TaskSession) ToAPI() map[string]interface{} {
result := map[string]interface{}{
"id": s.ID,
"task_id": s.TaskID,
"agent_execution_id": s.AgentExecutionID,
"container_id": s.ContainerID,
"agent_profile_id": s.AgentProfileID,
"executor_id": s.ExecutorID,
"executor_profile_id": s.ExecutorProfileID,
"environment_id": s.EnvironmentID,
"repository_id": s.RepositoryID,
"base_branch": s.BaseBranch,
"base_commit_sha": s.BaseCommitSHA,
"worktrees": s.Worktrees,
"state": string(s.State),
"started_at": s.StartedAt,
"updated_at": s.UpdatedAt,
}
// For backward compatibility, populate worktree_path and worktree_branch from first worktree
if len(s.Worktrees) > 0 {
result["worktree_path"] = s.Worktrees[0].WorktreePath
result["worktree_branch"] = s.Worktrees[0].WorktreeBranch
}
if s.Name != "" {
result["name"] = s.Name
}
if s.ErrorMessage != "" {
result["error_message"] = s.ErrorMessage
}
if s.CompletedAt != nil {
result["completed_at"] = s.CompletedAt
}
if s.Metadata != nil {
result["metadata"] = s.Metadata
}
if s.AgentProfileSnapshot != nil {
result["agent_profile_snapshot"] = s.AgentProfileSnapshot
}
if s.ExecutorSnapshot != nil {
result["executor_snapshot"] = s.ExecutorSnapshot
}
if s.EnvironmentSnapshot != nil {
result["environment_snapshot"] = s.EnvironmentSnapshot
}
if s.RepositorySnapshot != nil {
result["repository_snapshot"] = s.RepositorySnapshot
}
result["is_passthrough"] = s.IsPassthrough
if s.TaskEnvironmentID != "" {
result["task_environment_id"] = s.TaskEnvironmentID
}
// Office sessions carry an agent_profile_id; the frontend uses it
// to distinguish per-(task, agent) office sessions from per-launch
// kanban/quick-chat sessions, which drives "live" detection in the
// chat. Omit when blank so kanban responses look unchanged.
if s.AgentProfileID != "" {
result["agent_profile_id"] = s.AgentProfileID
}
return result
}
// Repository represents a workspace repository
type Repository struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
// LocalPath is the path to a local checkout; for provider-backed repos, this is
// populated after the repo is cloned/synced on the agent host.
LocalPath string `json:"local_path"`
// Provider fields describe the upstream source (e.g. github/gitlab) for future syncing.
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderHost string `json:"provider_host"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
RemoteURL string `json:"remote_url"`
DefaultBranch string `json:"default_branch"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
WorktreeBranchTemplate string `json:"worktree_branch_template"`
PullBeforeWorktree bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CopyFiles string `json:"copy_files"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// RepositoryScript represents a custom script for a repository
type RepositoryScript struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
Name string `json:"name"`
Command string `json:"command"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ExecutorType represents the executor runtime type.
type ExecutorType string
const (
ExecutorTypeLocal ExecutorType = "local"
ExecutorTypeWorktree ExecutorType = "worktree"
ExecutorTypeLocalDocker ExecutorType = "local_docker"
ExecutorTypeRemoteDocker ExecutorType = "remote_docker"
ExecutorTypeSprites ExecutorType = "sprites"
ExecutorTypeSSH ExecutorType = "ssh"
ExecutorTypeMockRemote ExecutorType = "mock_remote"
)
// IsRemoteExecutorType reports whether the given executor type represents
// a remote execution environment (including containerized environments like Docker).
// These environments run shells inside the container/VM, not on the host.
func IsRemoteExecutorType(t ExecutorType) bool {
switch t {
case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeSSH, ExecutorTypeMockRemote:
return true
default:
return false
}
}
// Runtime maps an ExecutorType to the runtime backend that hosts the
// agent subprocess. Mirrors executor.ExecutorTypeToBackend so callers
// outside the executor package can ask the property without importing
// it. The two are kept in lock-step intentionally — keep them in sync
// when adding a new ExecutorType.
func (t ExecutorType) Runtime() agentruntime.Runtime {
switch t {
case ExecutorTypeLocal, ExecutorTypeWorktree, ExecutorTypeMockRemote:
return agentruntime.RuntimeStandalone
case ExecutorTypeLocalDocker:
return agentruntime.RuntimeDocker
case ExecutorTypeRemoteDocker:
return agentruntime.RuntimeRemoteDocker
case ExecutorTypeSprites:
return agentruntime.RuntimeSprites
case ExecutorTypeSSH:
return agentruntime.RuntimeSSH
default:
return agentruntime.RuntimeStandalone
}
}
// IsContainerizedExecutorType reports whether the given executor type runs
// in a container/sandbox where shells must be executed inside the container
// via agentctl, not on the host. Single source of truth is
// Runtime().IsContainerized() so a new ExecutorType only has to declare its
// runtime once.
func IsContainerizedExecutorType(t ExecutorType) bool {
return t.Runtime().IsContainerized()
}
// IsAlwaysResumableRuntime reports whether the given runtime represents
// an executor that can always be resumed even without an explicit resume token.
func IsAlwaysResumableRuntime(runtime agentruntime.Runtime) bool {
return runtime == agentruntime.RuntimeSprites || runtime == agentruntime.RuntimeSSH
}
const (
ExecutorIDLocal = "exec-local"
ExecutorIDWorktree = "exec-worktree"
ExecutorIDLocalDocker = "exec-local-docker"
ExecutorIDSprites = "exec-sprites"
)
// ExecutorStatus represents executor availability.
type ExecutorStatus string
const (
ExecutorStatusActive ExecutorStatus = "active"
ExecutorStatusDisabled ExecutorStatus = "disabled"
)
// Executor represents an execution target.
type Executor struct {
ID string `json:"id"`
Name string `json:"name"`
Type ExecutorType `json:"type"`
Status ExecutorStatus `json:"status"`
IsSystem bool `json:"is_system"`
Resumable bool `json:"resumable"`
Config map[string]string `json:"config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// ExecutorRunning tracks an active executor instance for a session.
type ExecutorRunning struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
ExecutionProfileID string `json:"execution_profile_id"`
ExecutorID string `json:"executor_id"`
Runtime agentruntime.Runtime `json:"runtime,omitempty"`
Status string `json:"status"`
Resumable bool `json:"resumable"`
ResumeToken string `json:"resume_token,omitempty"`
LastMessageUUID string `json:"last_message_uuid,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
AgentctlURL string `json:"agentctl_url,omitempty"`
AgentctlPort int `json:"agentctl_port,omitempty"`
// PID is SSH-only: the agentctl PID on the *remote* host, used by the SSH
// executor's remote-pid stop path. It is 0 for local/standalone rows.
PID int `json:"pid,omitempty"`
// LocalPID is a host-local liveness handle: the PID of the standalone
// agentctl control-server process Kandev spawned on this host. Populated for
// local/standalone runtimes so a dead row is distinguishable from a live one
// without external context. 0 for SSH/remote rows (their process lives on
// another host — see PID). Never probe LocalPID for SSH rows.
LocalPID int `json:"local_pid,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProfileEnvVar represents an environment variable for an executor profile.
// Either Value (plain text) or SecretID (reference to a secret) should be set, not both.
type ProfileEnvVar struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
SecretID string `json:"secret_id,omitempty"`
}
// ExecutorProfile represents a named configuration preset for an executor.
type ExecutorProfile struct {
ID string `json:"id"`
ExecutorID string `json:"executor_id"`
Name string `json:"name"`
McpPolicy string `json:"mcp_policy,omitempty"`
Config map[string]string `json:"config,omitempty"`
PrepareScript string `json:"prepare_script,omitempty"`
CleanupScript string `json:"cleanup_script,omitempty"`
EnvVars []ProfileEnvVar `json:"env_vars,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// EnvironmentKind represents the runtime type for environments.
type EnvironmentKind string
const (
EnvironmentKindLocalPC EnvironmentKind = "local_pc"
EnvironmentKindDockerImage EnvironmentKind = "docker_image"
)
const (
EnvironmentIDLocal = "env-local"
)
// Environment represents a runtime environment configuration.
type Environment struct {
ID string `json:"id"`
Name string `json:"name"`
Kind EnvironmentKind `json:"kind"`
IsSystem bool `json:"is_system"`
WorktreeRoot string `json:"worktree_root,omitempty"`
ImageTag string `json:"image_tag,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
BuildConfig map[string]string `json:"build_config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// TaskEnvironmentStatus represents the lifecycle state of a task execution environment.
type TaskEnvironmentStatus string
const (
TaskEnvironmentStatusCreating TaskEnvironmentStatus = "creating"
TaskEnvironmentStatusReady TaskEnvironmentStatus = "ready"
TaskEnvironmentStatusStopped TaskEnvironmentStatus = "stopped"
TaskEnvironmentStatusFailed TaskEnvironmentStatus = "failed"
)
// TaskEnvironment represents a per-task execution environment instance.
// It owns the workspace (worktree/container/sandbox) and the agentctl control server.
// Multiple sessions can share the same TaskEnvironment.
type TaskEnvironment struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"` // Deprecated: use Repos. Kept for dual-write/backwards compat.
ExecutorType string `json:"executor_type"`
ExecutorID string `json:"executor_id"`
ExecutorProfileID string `json:"executor_profile_id"`
// AgentExecutionID was removed: executors_running owns the execution<->session
// mapping now. Read it via repo.GetExecutorRunningBySessionID(sessionID) when
// needed (the orchestrator does this in service_turns.go for WorkspaceInfo).
ControlPort int `json:"control_port"` // agentctl control port
Status TaskEnvironmentStatus `json:"status"`
// Type-specific fields. The single worktree fields below are legacy: with
// multi-repo tasks, the per-repo worktrees live on Repos. WorkspacePath
// continues to point at the agent workspace root (the task root when
// TaskDirName is set, otherwise the single repo's worktree path).
WorktreeID string `json:"worktree_id,omitempty"` // Deprecated: use Repos[i].WorktreeID
WorktreePath string `json:"worktree_path,omitempty"` // Deprecated: use Repos[i].WorktreePath
WorktreeBranch string `json:"worktree_branch,omitempty"` // Deprecated: use Repos[i].WorktreeBranch
WorkspacePath string `json:"workspace_path,omitempty"`
ContainerID string `json:"container_id,omitempty"`
SandboxID string `json:"sandbox_id,omitempty"`
// TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12").
// Set when the task uses the multi-repo task-directory layout
// (~/.kandev/tasks/{TaskDirName}/{RepoName}/).
TaskDirName string `json:"task_dir_name,omitempty"`
// Repos contains one entry per repository associated with this environment.
// Populated by repository getters. Empty for environments created before the
// multi-repo backfill ran with no legacy repository_id.
Repos []*TaskEnvironmentRepo `json:"repos,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RepoFor returns the per-repo environment row for repositoryID, or nil if not present.
func (te *TaskEnvironment) RepoFor(repositoryID string) *TaskEnvironmentRepo {
for _, r := range te.Repos {
if r.RepositoryID == repositoryID {
return r
}
}
return nil
}
// TaskEnvironmentRepo represents the per-repository state of a task environment.
// One row per repository associated with the task: each carries its own worktree
// reference and any per-repo preparation error.
type TaskEnvironmentRepo struct {
ID string `json:"id"`
TaskEnvironmentID string `json:"task_environment_id"`
RepositoryID string `json:"repository_id"`
BranchSlug string `json:"branch_slug,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
Position int `json:"position"`
ErrorMessage string `json:"error_message,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToAPI converts internal TaskEnvironment to API map.
func (te *TaskEnvironment) ToAPI() map[string]interface{} {
result := map[string]interface{}{
"id": te.ID,
"task_id": te.TaskID,
"repository_id": te.RepositoryID,
"executor_type": te.ExecutorType,
"executor_id": te.ExecutorID,
"executor_profile_id": te.ExecutorProfileID,
"status": string(te.Status),
"workspace_path": te.WorkspacePath,
"created_at": te.CreatedAt,
"updated_at": te.UpdatedAt,
}
// agent_execution_id is no longer carried on TaskEnvironment — see executors_running.
if te.ControlPort != 0 {
result["control_port"] = te.ControlPort
}
if te.WorktreeID != "" {
result["worktree_id"] = te.WorktreeID
}
if te.WorktreePath != "" {
result["worktree_path"] = te.WorktreePath
}
if te.WorktreeBranch != "" {
result["worktree_branch"] = te.WorktreeBranch
}
if te.ContainerID != "" {
result["container_id"] = te.ContainerID
}
if te.SandboxID != "" {
result["sandbox_id"] = te.SandboxID
}
if te.TaskDirName != "" {
result["task_dir_name"] = te.TaskDirName
}
if len(te.Repos) > 0 {
repos := make([]map[string]interface{}, 0, len(te.Repos))
for _, repo := range te.Repos {
repos = append(repos, repo.ToAPI())
}
result["repos"] = repos
}
return result
}
// ToAPI converts internal TaskEnvironmentRepo to API map.
func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} {
out := map[string]interface{}{
"id": r.ID,
"task_environment_id": r.TaskEnvironmentID,
"repository_id": r.RepositoryID,
"position": r.Position,
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
}
if r.WorktreeID != "" {
out["worktree_id"] = r.WorktreeID
}
if r.BranchSlug != "" {
out["branch_slug"] = r.BranchSlug
}
if r.WorktreePath != "" {
out["worktree_path"] = r.WorktreePath
}
if r.WorktreeBranch != "" {
out["worktree_branch"] = r.WorktreeBranch
}
if r.ErrorMessage != "" {
out["error_message"] = r.ErrorMessage
}
return out
}
// TaskPlan represents a plan associated with a task
type TaskPlan struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"` // "agent" or "user"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ImplementationStartedAt *time.Time `json:"implementation_started_at,omitempty"`
ImplementationStartedSessionID *string `json:"implementation_started_session_id,omitempty"`
ImplementationStartedBy *string `json:"implementation_started_by,omitempty"`
}
// TaskPlanRevision is one immutable snapshot in the revision history of a task plan.
// Revisions are the source of truth for history; TaskPlan stores the latest revision's content as HEAD.
type TaskPlanRevision struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RevisionNumber int `json:"revision_number"`
Title string `json:"title"`
Content string `json:"content"`
AuthorKind string `json:"author_kind"` // "agent" | "user"
AuthorName string `json:"author_name"` // display snapshot (agent profile name or user identifier)
RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // bumps on coalesce merge
}
// TaskWalkthrough is an agent-authored guided code tour attached to a task.
// It is the "what & where" of a review narration: an ordered list of Steps,
// each anchored to a concrete repo/file/line, rendered as popovers over the
// review diff. Mirrors the TaskPlan artifact pattern (one per task, agent-authored).
type TaskWalkthrough struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Title string `json:"title"`
Steps []WalkthroughStep `json:"steps"`
CreatedBy string `json:"created_by"` // always "agent"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WalkthroughStep is a single anchored stop in a TaskWalkthrough. Text is
// markdown shown in the popover; File/Line locate the anchor inside the diff
// (Repo disambiguates in multi-repo reviews, LineEnd optionally spans a range).
type WalkthroughStep struct {
Title string `json:"title,omitempty"`
Repo string `json:"repo,omitempty"`
File string `json:"file"`
Line int `json:"line"`
LineEnd int `json:"line_end,omitempty"`
Text string `json:"text"`
}
// ReviewRunStatus is the lifecycle state of a native code-review pass.
type ReviewRunStatus string
// Review run statuses. A run only leaves pending/running once, and every
// terminal state is final — an interrupted run is cancelled, never resumed.
const (
ReviewRunPending ReviewRunStatus = "pending"
ReviewRunRunning ReviewRunStatus = "running"
ReviewRunCompleted ReviewRunStatus = "completed"
ReviewRunFailed ReviewRunStatus = "failed"
ReviewRunCancelled ReviewRunStatus = "cancelled"
)
// IsTerminal reports whether the run has finished and cannot change again.
func (s ReviewRunStatus) IsTerminal() bool {
return s == ReviewRunCompleted || s == ReviewRunFailed || s == ReviewRunCancelled
}
// ReviewRunTrigger records what started a review pass.
type ReviewRunTrigger string
// Review run triggers.
const (
ReviewTriggerManual ReviewRunTrigger = "manual"
ReviewTriggerWorkflowStep ReviewRunTrigger = "workflow_step"
ReviewTriggerAgent ReviewRunTrigger = "agent"
)
// ReviewSeverity ranks how much a finding should worry the reader.
type ReviewSeverity string
// Review severities, most severe first.
const (
ReviewSeverityBlocker ReviewSeverity = "blocker"
ReviewSeverityMajor ReviewSeverity = "major"
ReviewSeverityMinor ReviewSeverity = "minor"
ReviewSeverityNit ReviewSeverity = "nit"
)
// ValidReviewSeverity reports whether s is a known severity.
func ValidReviewSeverity(s ReviewSeverity) bool {
switch s {
case ReviewSeverityBlocker, ReviewSeverityMajor, ReviewSeverityMinor, ReviewSeverityNit:
return true
default:
return false
}
}
// ReviewFindingStatus is the human disposition of a finding. Findings are
// advisory: the human resolves or dismisses, kandev never does it for them.
type ReviewFindingStatus string
// Review finding statuses.
const (
ReviewFindingOpen ReviewFindingStatus = "open"
ReviewFindingResolved ReviewFindingStatus = "resolved"
ReviewFindingDismissed ReviewFindingStatus = "dismissed"
)
// ValidReviewFindingStatus reports whether s is a known finding status.
func ValidReviewFindingStatus(s ReviewFindingStatus) bool {
switch s {
case ReviewFindingOpen, ReviewFindingResolved, ReviewFindingDismissed:
return true
default:
return false
}
}
// ReviewAnnotationSide selects which side of the diff a finding anchors to,
// matching the frontend annotation sides.
const (
ReviewSideAdditions = "additions"
ReviewSideDeletions = "deletions"
)
// TaskReviewRun is one native code-review pass over a task's changed files.
// A task keeps a bounded history of runs; findings reference the run that
// produced them so the UI can attribute and supersede them.
type TaskReviewRun struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Trigger ReviewRunTrigger `json:"trigger"`
WorkflowStepID string `json:"workflow_step_id"`
AgentID string `json:"agent_id"`
Model string `json:"model"`
Status ReviewRunStatus `json:"status"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
Summary string `json:"summary"`
FindingCount int `json:"finding_count"`
FileCount int `json:"file_count"`
RepositoryCount int `json:"repository_count"`
PromptTokens int `json:"prompt_tokens"`
ResponseTokens int `json:"response_tokens"`
DurationMs int `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// TaskReviewFinding is one anchored, advisory review comment produced by a
// review run. It renders in the Changes/Review diff at File/StartLine..EndLine
// of Repository, and carries FileDiffHash so a client can tell whether the
// diff has moved under it (see ../../../../docs/specs/native-code-review/spec.md).
type TaskReviewFinding struct {
ID string `json:"id"`
RunID string `json:"run_id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
RepositoryName string `json:"repository_name"`
FilePath string `json:"file_path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
Side string `json:"side"`
Severity ReviewSeverity `json:"severity"`
Category string `json:"category"`
Title string `json:"title"`
Body string `json:"body"`
Suggestion string `json:"suggestion"`
AnchorText string `json:"anchor_text"`
FileDiffHash string `json:"file_diff_hash"`
Status ReviewFindingStatus `json:"status"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ReviewFindingKey identifies a finding's anchor for supersede matching: a new
// run that reports the same issue at the same place replaces the older row
// instead of listing it twice.
type ReviewFindingKey struct {
RepositoryName string
FilePath string
StartLine int
EndLine int
Title string
}
// SupersedeKey returns the finding's supersede identity.
func (f *TaskReviewFinding) SupersedeKey() ReviewFindingKey {
return ReviewFindingKey{
RepositoryName: f.RepositoryName,
FilePath: f.FilePath,
StartLine: f.StartLine,
EndLine: f.EndLine,
Title: f.Title,
}
}
// TaskDocument represents a named document (plan, spec, notes, etc.) associated with a task.
// The key uniquely identifies the document within a task (e.g., "plan", "spec", "notes").
type TaskDocument struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
Key string `json:"key" db:"key"`
Type string `json:"type" db:"type"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
AuthorKind string `json:"author_kind" db:"author_kind"`
AuthorName string `json:"author_name" db:"author_name"`
Filename string `json:"filename,omitempty" db:"filename"`
MimeType string `json:"mime_type,omitempty" db:"mime_type"`
SizeBytes int64 `json:"size_bytes,omitempty" db:"size_bytes"`
DiskPath string `json:"-" db:"disk_path"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// TaskDocumentRevision is one immutable snapshot in the revision history of a task document.
// Revisions are the source of truth for history; TaskDocument stores the latest revision's content as HEAD.
type TaskDocumentRevision struct {
ID string `json:"id" db:"id"`
TaskID string `json:"task_id" db:"task_id"`
DocumentKey string `json:"document_key" db:"document_key"`
RevisionNumber int `json:"revision_number" db:"revision_number"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
AuthorKind string `json:"author_kind" db:"author_kind"`
AuthorName string `json:"author_name" db:"author_name"`
RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty" db:"revert_of_revision_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SessionFileReview tracks per-file review state within a session
type SessionFileReview struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
FilePath string `json:"file_path"`
Reviewed bool `json:"reviewed"`
DiffHash string `json:"diff_hash"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToAPI converts internal Task to API type
func (t *Task) ToAPI() *v1.Task {
// Convert TaskRepository models to API types
var repositories []v1.TaskRepository
for _, repo := range t.Repositories {
repositories = append(repositories, v1.TaskRepository{
ID: repo.ID,
TaskID: repo.TaskID,
RepositoryID: repo.RepositoryID,
BaseBranch: repo.BaseBranch,
Position: repo.Position,
Metadata: repo.Metadata,
CreatedAt: repo.CreatedAt,
UpdatedAt: repo.UpdatedAt,
})
}
result := &v1.Task{
ID: t.ID,
WorkspaceID: t.WorkspaceID,
WorkflowID: t.WorkflowID,
Title: t.Title,
Description: t.Description,
State: t.State,
Priority: t.Priority,
Repositories: repositories,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
Metadata: t.Metadata,
IsEphemeral: t.IsEphemeral,
ParentID: t.ParentID,
}
if t.Identifier != "" {
result.Identifier = t.Identifier
}
return result
}