Files
kandev/apps/backend/internal/task/models/models.go
T
2026-07-27 18:10:12 +01:00

1634 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"
// SessionMetaKeyGitCredentialSnapshot records the non-secret Git credential
// routing contract that successfully launched or resumed a session.
const SessionMetaKeyGitCredentialSnapshot = "git_credential_snapshot"
// GitCredentialSnapshot is launch-time display metadata. It never contains a
// token, broker lease, helper command, credential file, or SSH key detail.
type GitCredentialSnapshot struct {
Version int `json:"version"`
Policy string `json:"policy"`
Source string `json:"source"`
WorkspaceMethod string `json:"workspace_method,omitempty"`
Actor string `json:"actor"`
Transport string `json:"transport"`
ExecutorType string `json:"executor_type,omitempty"`
CapturedAt time.Time `json:"captured_at"`
}
// 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
}