2a1f7c2edf
* feat: sync workflows from a configured github repo Per-workspace setting pointing at a GitHub repo directory of portable workflow export files. A background poller fetches it on an interval and creates, updates, or deletes the matching workflows; a force-sync endpoint bypasses the content-hash check. Synced workflows carry source/source_path provenance and coexist with manual ones; changes that cannot be applied safely (steps or workflows that still hold tasks, ambiguous step names, unparseable files) surface as warnings in the new settings section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): fill workflow sync form from a pasted github link A new Repository link field parses plain repo URLs, SSH remotes, and tree/blob links (which also carry branch and directory) into the structured owner/repo/branch/path fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): simplify workflow sync form to link, branch, and interval The repository link is now the only repo input: owner, repo, and directory resolve from the pasted URL (echoed as a summary under the field), and a stored config renders back into a canonical tree link via buildGitHubRepoUrl. Branch and poll interval stay individually editable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): collapse workflow sync into a button, dialog, and status card The Workflows settings page now shows only a GitHub Sync button by default; the link/branch/interval form lives in a dialog that closes on successful save or removal. Once configured, a compact status card (mirroring the GitHub connection-status card) shows the synced repo, branch, directory, last result, warnings, and a Sync now action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): move github sync button into the workflows page header The GitHub Sync trigger now stacks under the Workspace settings button on the top right, and the extra separator around the sync area is gone — unconfigured workspaces show no sync chrome at all, configured ones show just the status card above the workflow list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): move github sync button next to the workflow actions GitHub Sync now sits in the Workflows section toolbar alongside Export All, Import, and Add Workflow; the status card renders inside the section above the workflow list. The toolbar moved to its own component to keep the page under the file-size limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): separate sync status card from the workflow list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): align sync status card with the workflow list gutter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: repair local edits to synced workflows on periodic sync Periodic syncs previously skipped applying when the repo content hash was unchanged, so local UI edits to a synced workflow were only reverted by a manual Sync now. Every sync now reconciles against the repo; the applier compares each step against its definition and only writes (and broadcasts) actual differences, so a no-drift sync stays silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: make github-synced workflows read-only Workflows with source=github reject UI mutations: workflow rename, description, agent-profile, and delete return 409 from the task-domain handlers; step create/update/delete/reorder and template application are guarded in the workflow controller. The sync applier writes through the service/repo layer and stays exempt. The frontend disables the card's inputs, save/delete, and the step pipeline editor for synced workflows, with a tooltip pointing at the synced repository. Drift repair on sync remains as the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: optional workflow sync polling and repo-derived branch picker A per-workspace auto-sync toggle (poll_enabled, default on) controls the background polling loop; when off the workspace only syncs via Sync now. The dialog's branch field becomes a select fed by the repository's actual branches (same loader the task-create dialog uses), falling back to free text until branches load. Existing dev databases gain the new column via an idempotent ALTER. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: release synced workflows when the sync config is removed Deleting a workspace's workflow sync configuration converts its GitHub-sourced workflows back to manual ownership (source=manual), so they become editable instead of being stranded read-only. Release runs before the config row is deleted so a failure keeps the config for retry. The settings page reloads after removal (and after syncs that changed workflows) so cards unlock without a manual refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): drop branch selector and compact the auto-sync row The pasted link already carries the branch (main by default), so the dialog no longer needs a branch field — the resolved summary echoes it. The auto-sync toggle is now one compact row with the interval inline and a single short helper line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address PR review feedback Backend: fail-open read-only guard on workflow lookup errors (fixes MCP step handler CI failures); poller Stop holds its mutex through drain; per-workspace lock serializes syncs against config delete/replace; rollback workflow creation when provenance stamping fails; idiomatic duplicate-column migration via db.IsDuplicateColumnError; branch-name validation and a 30-day interval cap; generic 500s with logged detail; WS read-only rejections use CONFLICT with the real reason; e2e reset aborts when sync-config cleanup fails; test hygiene (t.Cleanup, SetMaxOpenConns). Frontend: malformed percent escapes parse as invalid instead of throwing; save disabled during initial load and for invalid intervals; Remove disabled while saving; stale async config responses discarded; delete-dialog confirms re-check read-only; focusable Synced badge; explicit button types; failed polls labeled 'last attempt'; unique warning list keys; test cleanup via afterEach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard workflow sync test fake against concurrent poller access The poller invokes the fake applier from its own goroutine while the synctest test asserts on call counts; -race caught the unguarded reads. Also documents that the per-workspace lock is deliberately held across the GitHub fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1294 lines
54 KiB
Go
1294 lines
54 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")
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 user-selected session runtime settings
|
|
// (model, mode, and dynamic config options) separately from the immutable-ish
|
|
// agent profile snapshot that seeded the session.
|
|
const SessionMetaKeyRuntimeConfig = "runtime_config"
|
|
|
|
// SessionRuntimeConfig is persisted under
|
|
// TaskSession.Metadata[SessionMetaKeyRuntimeConfig]. It captures live ACP
|
|
// settings chosen after session start so process resume can restore the same
|
|
// model/config instead of reverting to profile defaults.
|
|
type SessionRuntimeConfig struct {
|
|
Model string `json:"model,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
ConfigOptions map[string]string `json:"config_options,omitempty"`
|
|
}
|
|
|
|
// 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) {
|
|
if metadata == nil {
|
|
return SessionRuntimeConfig{}, false
|
|
}
|
|
raw, ok := metadata[SessionMetaKeyRuntimeConfig]
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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 primary 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 := 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
|
|
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"`
|
|
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
|
|
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.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"`
|
|
ProviderOwner string `json:"provider_owner"`
|
|
ProviderName string `json:"provider_name"`
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// 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
|
|
}
|