1634 lines
67 KiB
Go
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
|
|
}
|