00c7469d81
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2938 lines
127 KiB
Go
2938 lines
127 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"
|
|
createdAtField = "created_at"
|
|
)
|
|
|
|
// ListMessagesOptions defines pagination options for listing messages
|
|
type ListMessagesOptions struct {
|
|
Limit int
|
|
Before string
|
|
After string
|
|
Sort string
|
|
AuthorType string
|
|
// AuthorTypes narrows by any of the listed authors (IN); when non-empty
|
|
// it takes precedence over AuthorType. TaskID, when non-empty, narrows
|
|
// to rows for that task.
|
|
AuthorTypes []string
|
|
TaskID string
|
|
Around 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"
|
|
// Automation target metadata is written to continuation tasks so a
|
|
// change from hidden to visible ownership or from repository-backed to
|
|
// repository-free execution cannot silently reuse the old task.
|
|
MetaKeyAutomationTaskMode = "automation_task_mode"
|
|
MetaKeyAutomationRepositoryMode = "automation_repository_mode"
|
|
MetaKeyDeferredLaunch = "deferred_launch"
|
|
// MetaKeyWorkflowInitialSession is a write-once task-local snapshot of
|
|
// the first session identity used by workflow session targeting.
|
|
MetaKeyWorkflowInitialSession = "workflow_initial_session"
|
|
// MetaKeyWorkflowSessionRoute stores the bounded prepared/committed route
|
|
// identity used to retry an explicit workflow session selection.
|
|
MetaKeyWorkflowSessionRoute = "workflow_session_route"
|
|
// MetaKeyQueuedMoveExitPending identifies a queued manual move whose
|
|
// source-step on_exit side effect is not yet complete. Its value records the
|
|
// source step so recovery can resume the work after a restart.
|
|
MetaKeyQueuedMoveExitPending = "queued_move_exit_pending"
|
|
// MetaKeyQueuedMoveExitCompleted is durable evidence that the source-step
|
|
// on_exit side effect for a queued manual move finished. Promotion must not
|
|
// enter the destination until this marker exists.
|
|
MetaKeyQueuedMoveExitCompleted = "queued_move_exit_completed"
|
|
// MetaKeyQueuePromotionPending is a one-shot token for destination entry
|
|
// after queue promotion. It prevents duplicate task.queue_promoted events
|
|
// from repeating on_enter or auto-start behavior.
|
|
MetaKeyQueuePromotionPending = "queue_promotion_pending"
|
|
// MetaKeyWorkflowMovePending carries the one-shot entry options of a direct
|
|
// (immediately applied) workflow move whose target-step entry has not yet
|
|
// run. Its value records the source step, the move ID, and the encoded
|
|
// entry options. The public task.moved event carries only the move ID; the
|
|
// instructions and profile choice ride on this transient marker and are
|
|
// cleared once the target entry is dispatched.
|
|
MetaKeyWorkflowMovePending = "workflow_move_pending"
|
|
// MetaKeyManualMoveLifecyclePending identifies an admitted manual move whose
|
|
// task.moved lifecycle must finish before feeder reconciliation. Its value
|
|
// records the source step so stale deliveries cannot run the wrong exit.
|
|
MetaKeyManualMoveLifecyclePending = "manual_move_lifecycle_pending"
|
|
// MetaKeyManualMoveLifecycleCompleted records that the admitted manual move
|
|
// lifecycle finished. It is cleared once its continuation has run and no
|
|
// MetaKeyManualMoveLifecyclePending token remains, so it does not
|
|
// accumulate as permanent startup-recovery work; a fresh manual move
|
|
// replaces it with a new pending token before it would be cleared.
|
|
MetaKeyManualMoveLifecycleCompleted = "manual_move_lifecycle_completed"
|
|
// MetaKeyAppliedDeferredMoves stores deferred move IDs that have already
|
|
// been applied, preventing a stale queue rollback from replaying one.
|
|
MetaKeyAppliedDeferredMoves = "applied_deferred_moves"
|
|
// DeferredLaunchStartWhenUnblockedKey marks a deferred launch intent as
|
|
// belonging to a task dependency chain rather than to WIP overflow. The
|
|
// record itself is identical; the flag lets dependency resolution recognise
|
|
// its own intents and keeps a WIP-only intent from being read as a chain
|
|
// step (and vice versa).
|
|
DeferredLaunchStartWhenUnblockedKey = "start_when_unblocked"
|
|
// DeferredLaunchUserIDKey preserves the authenticated creator so a
|
|
// selector-backed deferred task-create launch can update that user's
|
|
// task_create history after the launch is promoted by the workflow engine.
|
|
// It is server-owned state inside the protected deferred launch record and
|
|
// must not be accepted from task metadata request surfaces.
|
|
DeferredLaunchUserIDKey = "user_id"
|
|
// DeferredLaunchRecordRecentUseKey marks a deferred launch that originated
|
|
// from a selector-backed task-create surface and is therefore eligible to
|
|
// update task_create profile history after promotion. It is server-owned
|
|
// state and is omitted from task DTOs.
|
|
DeferredLaunchRecordRecentUseKey = "record_recent_use"
|
|
// 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"
|
|
// MetaKeyAutoStartGuard is a permanent marker set at create time on tasks
|
|
// where both the watcher's synchronous Path B (autoStartReviewTask) and the
|
|
// promotion's event-driven Path A (autoStartTaskForStep) could both try to
|
|
// launch an agent. Its presence signals that the one-shot MetaKeyAutoStartClaimed
|
|
// token governs who may launch. Never removed after task creation.
|
|
MetaKeyAutoStartGuard = "auto_start_guard"
|
|
// MetaKeyAutoStartClaimed is a one-shot token set alongside MetaKeyAutoStartGuard.
|
|
// Both auto-start paths atomically remove this key; only the first removal
|
|
// succeeds and that path proceeds to StartTask. The other path skips launch
|
|
// because the winner will (or already did) handle it.
|
|
// Absent on ordinary (non-watcher) auto-start tasks, which launch normally.
|
|
MetaKeyAutoStartClaimed = "auto_start_claimed"
|
|
// MetaKeyInterruptedAt is set by startup reconciliation when a task's
|
|
// session was mid-turn (STARTING/RUNNING) when the backend died. Its
|
|
// presence makes the task DTO report `interrupted: true` so task-list
|
|
// surfaces show the red interruption icon; the orchestrator removes the
|
|
// key when a session of the task next enters STARTING/RUNNING.
|
|
MetaKeyInterruptedAt = "interrupted_at"
|
|
// MetaKeyAutoStartFailed is set when a workflow step's auto_start_agent
|
|
// on_enter action fails to launch a run (kanban StartTask error, or an
|
|
// Office task queue-run error/unresolvable agent). Its presence makes the
|
|
// task DTO report `auto_start_failed: true` so the failure surfaces on
|
|
// the card instead of only in backend logs; the orchestrator removes the
|
|
// key when a session of the task next enters STARTING/RUNNING (mirrors
|
|
// MetaKeyInterruptedAt).
|
|
MetaKeyAutoStartFailed = "auto_start_failed"
|
|
// MetaKeyAgentTitlePending marks tasks whose provisional title still needs
|
|
// the first eligible agent session to replace it.
|
|
MetaKeyAgentTitlePending = "agent_title_pending"
|
|
// MetaKeyAgentTitleOwnerSessionID records the one session that atomically
|
|
// claimed the first-turn title handoff for a pending task.
|
|
MetaKeyAgentTitleOwnerSessionID = "agent_title_owner_session_id"
|
|
// MetaKeyPortForwardingEnabled controls whether a task exposes its
|
|
// session port-forwarding controls in the task UI.
|
|
MetaKeyPortForwardingEnabled = "port_forwarding_enabled"
|
|
// MetaKeyAutomationTargetTaskID binds a merged-PR automation run to the
|
|
// task selected by its event. The archive handler enforces this value.
|
|
MetaKeyAutomationTargetTaskID = "automation_target_task_id"
|
|
// Parent-question message metadata is durable state for an autopilot child
|
|
// waiting for a decision from its direct parent. The question ID is also
|
|
// the child clarification message ID, so a parent reply can be idempotent
|
|
// without a second persistence table.
|
|
MetaKeyParentQuestionID = "parent_question_id"
|
|
MetaKeyParentQuestion = "parent_question"
|
|
MetaKeyParentQuestionParentID = "parent_task_id"
|
|
MetaKeyParentQuestionChildID = "child_task_id"
|
|
MetaKeyParentQuestionStatus = "parent_question_status"
|
|
MetaKeyParentQuestionResponse = "parent_question_response"
|
|
// MetaKeyInitialSessionRuntimeConfig is a launch-only seed for the first
|
|
// session created for a task. PrepareSession consumes it into session
|
|
// runtime overrides and never leaves it in session metadata.
|
|
MetaKeyInitialSessionRuntimeConfig = "initial_session_runtime_config"
|
|
// MetaKeyInitialSessionRuntimeConfigProfileID identifies the agent profile
|
|
// that produced the launch-only runtime seed. A seed is valid only for this
|
|
// profile, even if task profile selection changes before the first launch.
|
|
MetaKeyInitialSessionRuntimeConfigProfileID = "initial_session_runtime_config_profile_id"
|
|
// MetaKeyAutoStartOnCreate is a positive opt-in a task creator stamps when
|
|
// it wants task.created to evaluate the destination step's on_enter
|
|
// actions immediately, as if creation were itself a transition into that
|
|
// step. Absence is the default and preserves existing behavior for every
|
|
// other producer (REST/MCP/WS create with or without start_agent /
|
|
// prepare_session, CreateChildTask, etc.) — those already have their own
|
|
// launch decision, and task.created must not second-guess it. Set today
|
|
// only by CreateOfficeTaskInWorkflow for materialized heavy-routine runs,
|
|
// whose Routine workflow start step has no other transition to carry it
|
|
// into an auto_start_agent evaluation.
|
|
MetaKeyAutoStartOnCreate = "auto_start_on_create"
|
|
// MetaKeyAutoStartOnCreateInFlight is a durable hand-off marker for an
|
|
// auto-start-on-create launch. The original intent is consumed before the
|
|
// detached launch starts, but this marker remains until a session or run
|
|
// is durable. That lets startup recovery retry a process that exits in the
|
|
// gap instead of losing the last recovery signal.
|
|
MetaKeyAutoStartOnCreateInFlight = "auto_start_on_create_in_flight"
|
|
// MetaKeyStepHandoffCarry is a single-slot, task-scoped token carrying one
|
|
// consuming transition's completion handoff exactly one hop, to the next
|
|
// step's first dispatched prompt. Its value is a StepHandoffCarryToken.
|
|
// Recording it replaces any existing token (single-slot by construction);
|
|
// claiming it removes it. See REQ-TASKS-SIGNAL-PAYLOAD-DELIVERY-001.
|
|
MetaKeyStepHandoffCarry = "step_handoff_carry"
|
|
)
|
|
|
|
// WorkflowInitialSessionSnapshot identifies the immutable task-initial
|
|
// conversation and the logical profile selected when it was created.
|
|
type WorkflowInitialSessionSnapshot struct {
|
|
SessionID string `json:"session_id"`
|
|
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
|
}
|
|
|
|
// WorkflowSessionRoute records one in-flight or committed explicit recipient
|
|
// selection. It is replaced by the next route operation rather than growing a
|
|
// task history.
|
|
type WorkflowSessionRoute struct {
|
|
OperationID string `json:"operation_id"`
|
|
DestinationStepID string `json:"destination_step_id"`
|
|
// EntryIdentity binds the route to the workflow entry that created it. It
|
|
// prevents a late replay from consuming a newer route for the same step.
|
|
EntryIdentity string `json:"entry_identity,omitempty"`
|
|
TargetKind string `json:"target_kind"`
|
|
TargetStepID string `json:"target_step_id,omitempty"`
|
|
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
|
SourceSessionID string `json:"source_session_id,omitempty"`
|
|
DestinationID string `json:"destination_session_id,omitempty"`
|
|
Phase string `json:"phase"`
|
|
}
|
|
|
|
// LoadWorkflowSessionRoute decodes the bounded route record stored in task
|
|
// metadata. Invalid or incomplete values are ignored so a stale record cannot
|
|
// redirect a workflow entry to an unrelated session.
|
|
func LoadWorkflowSessionRoute(metadata map[string]interface{}) (WorkflowSessionRoute, bool) {
|
|
value, ok := metadata[MetaKeyWorkflowSessionRoute]
|
|
if !ok {
|
|
return WorkflowSessionRoute{}, false
|
|
}
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return WorkflowSessionRoute{}, false
|
|
}
|
|
var route WorkflowSessionRoute
|
|
if err := json.Unmarshal(payload, &route); err != nil {
|
|
return WorkflowSessionRoute{}, false
|
|
}
|
|
if route.OperationID == "" || route.DestinationStepID == "" || route.TargetKind == "" || route.Phase == "" {
|
|
return WorkflowSessionRoute{}, false
|
|
}
|
|
return route, true
|
|
}
|
|
|
|
// WorkflowSessionBinding records the latest session selected when a direct
|
|
// profile workflow step was entered. The session pointer is nullable because
|
|
// deleting a session must preserve the logical profile for a later fresh
|
|
// fallback.
|
|
type WorkflowSessionBinding struct {
|
|
TaskID string `json:"task_id"`
|
|
TargetKey string `json:"target_key"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
OperationID string `json:"operation_id"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// LoadWorkflowInitialSessionSnapshot decodes the write-once task metadata
|
|
// value. Invalid or incomplete values are treated as unavailable so callers
|
|
// can apply the conservative legacy fallback.
|
|
func LoadWorkflowInitialSessionSnapshot(metadata map[string]interface{}) (WorkflowInitialSessionSnapshot, bool) {
|
|
value, ok := metadata[MetaKeyWorkflowInitialSession]
|
|
if !ok {
|
|
return WorkflowInitialSessionSnapshot{}, false
|
|
}
|
|
object, ok := value.(map[string]interface{})
|
|
if !ok {
|
|
return WorkflowInitialSessionSnapshot{}, false
|
|
}
|
|
sessionID, _ := object["session_id"].(string)
|
|
profileID, _ := object["agent_profile_id"].(string)
|
|
if sessionID == "" {
|
|
return WorkflowInitialSessionSnapshot{}, false
|
|
}
|
|
return WorkflowInitialSessionSnapshot{SessionID: sessionID, AgentProfileID: profileID}, true
|
|
}
|
|
|
|
// StepHandoffCarryToken is the JSON shape stored under
|
|
// tasks.metadata[MetaKeyStepHandoffCarry]. Stamp is a fresh unique value
|
|
// (uuid.NewString()) minted on every write, compared by the claim's
|
|
// compare-and-swap alongside StepID; it must never be content-derived.
|
|
type StepHandoffCarryToken struct {
|
|
Handoff string `json:"handoff"`
|
|
StepID string `json:"step_id"`
|
|
Stamp string `json:"stamp"`
|
|
}
|
|
|
|
// IsAgentTitlePending reports whether task metadata contains the durable
|
|
// pending title marker. JSON rehydration produces bool values, while a
|
|
// few in-process callers may provide typed metadata, so only an explicit true
|
|
// value enables the capability.
|
|
func IsAgentTitlePending(metadata map[string]interface{}) bool {
|
|
pending, ok := metadata[MetaKeyAgentTitlePending].(bool)
|
|
return ok && pending
|
|
}
|
|
|
|
// AgentTitleOwnerSessionID returns the session that owns the pending title
|
|
// handoff, if one has been claimed.
|
|
func AgentTitleOwnerSessionID(metadata map[string]interface{}) string {
|
|
owner, _ := metadata[MetaKeyAgentTitleOwnerSessionID].(string)
|
|
return owner
|
|
}
|
|
|
|
// IsAgentTitleOwner reports whether sessionID owns the pending title handoff.
|
|
func IsAgentTitleOwner(metadata map[string]interface{}, sessionID string) bool {
|
|
return sessionID != "" && IsAgentTitlePending(metadata) && AgentTitleOwnerSessionID(metadata) == sessionID
|
|
}
|
|
|
|
// HasAutoStartOnCreateIntent reports whether task metadata carries the
|
|
// positive MetaKeyAutoStartOnCreate opt-in. Only an explicit true value
|
|
// counts — absence (the default for nearly every task producer) must never
|
|
// be read as "please auto-start me".
|
|
func HasAutoStartOnCreateIntent(metadata map[string]interface{}) bool {
|
|
intent, ok := metadata[MetaKeyAutoStartOnCreate].(bool)
|
|
return ok && intent
|
|
}
|
|
|
|
// HasAutoStartOnCreateInFlight reports whether a launch attempt still owns
|
|
// the durable hand-off marker for a create-time auto-start. Only an explicit
|
|
// true value counts; JSON rehydration and in-process callers use the same
|
|
// representation as the other lifecycle markers.
|
|
func HasAutoStartOnCreateInFlight(metadata map[string]interface{}) bool {
|
|
inFlight, ok := metadata[MetaKeyAutoStartOnCreateInFlight].(bool)
|
|
return ok && inFlight
|
|
}
|
|
|
|
// 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"
|
|
// SessionMetaKeyCompletionFollowUp marks a completed conversation that was
|
|
// explicitly resumed for conversational follow-up. It is not workflow
|
|
// ownership and must not be treated as a task-state transition.
|
|
SessionMetaKeyCompletionFollowUp = "completion_follow_up"
|
|
// SessionMetaKeyWorkflowProfileSwitchStopIntent identifies the transient
|
|
// coordination record used to suppress the lifecycle event caused by a
|
|
// parked workflow profile switch.
|
|
SessionMetaKeyWorkflowProfileSwitchStopIntent = "workflow_profile_switch_stop_intent"
|
|
// SessionMetaKeyOrigin identifies immutable task-session provenance. Unlike
|
|
// IsPrimary, it never changes when the user selects another conversation tab.
|
|
SessionMetaKeyOrigin = "origin"
|
|
SessionOriginTaskInitial = "task_initial"
|
|
SessionMetaKeyContextWindow = "context_window"
|
|
SessionMetaKeyContextCompactionCount = "context_compaction_count"
|
|
// SessionMetaKeyRecoveryResolvedAt stores the server timestamp of the
|
|
// latest successful agent boot. Recovery cards compare this timestamp with
|
|
// their own creation time, so the result survives transcript write failures.
|
|
SessionMetaKeyRecoveryResolvedAt = "recovery_resolved_at"
|
|
// SessionMetaKeyInitialCreatePromptPassthrough stores the durable
|
|
// execution and turn evidence for a creation prompt admitted before a
|
|
// passthrough agent.running event. It prevents recovery from evaluating
|
|
// the same on_turn_start transition a second time after restart.
|
|
SessionMetaKeyInitialCreatePromptPassthrough = "initial_create_prompt_passthrough"
|
|
)
|
|
|
|
// IsCompletionFollowUpSession reports whether a session was explicitly
|
|
// reopened only to continue its completed conversation. The marker remains
|
|
// meaningful after the session settles back to WAITING_FOR_INPUT.
|
|
func IsCompletionFollowUpSession(metadata map[string]interface{}) bool {
|
|
followUp, ok := metadata[SessionMetaKeyCompletionFollowUp].(bool)
|
|
return ok && followUp
|
|
}
|
|
|
|
// WorkflowProfileSwitchStopIntent binds a deliberate parked-session stop to
|
|
// one exact runtime execution. Stamp is compared before the metadata value is
|
|
// marked consumed, so a delayed event cannot consume a newer switch intent.
|
|
type WorkflowProfileSwitchStopIntent struct {
|
|
ExecutionID string `json:"execution_id"`
|
|
Stamp string `json:"stamp"`
|
|
// Consumed is a durable tombstone for the matching terminal callback. It
|
|
// remains in session metadata so delayed callbacks after a restart cannot
|
|
// advance the workflow.
|
|
Consumed bool `json:"consumed,omitempty"`
|
|
}
|
|
|
|
// 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"
|
|
|
|
// SessionMetaKeyOriginalEffectiveConfig stores the write-once effective model
|
|
// and selectable ACP option values after profile settings settle. It is the
|
|
// restore source for workflow rules and is intentionally separate from the
|
|
// provider-default comparison baseline and mutable runtime state.
|
|
const SessionMetaKeyOriginalEffectiveConfig = "original_effective_config"
|
|
|
|
// 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"
|
|
|
|
// SessionMetaKeyMCPAttachmentState records bounded, safe evidence about MCP
|
|
// attachment for this task session and its immediate prior attempts.
|
|
const SessionMetaKeyMCPAttachmentState = "mcp_attachment_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"
|
|
|
|
// TurnMetaKeyWorkflowStepIDAtStart records the workflow step the turn's task
|
|
// was in when the turn started. Absent when the task held no step.
|
|
const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start"
|
|
|
|
// TurnMetaKeyLifecycleOnly marks a turn created only to parent a lifecycle
|
|
// message (for example the agent_boot script_execution message on resume).
|
|
// A lifecycle turn never reflects real agent work and must never be current-
|
|
// turn authority, so every current-turn resolution site excludes it.
|
|
const TurnMetaKeyLifecycleOnly = "lifecycle_only"
|
|
|
|
// TurnMetaKeyErrorTerminated marks a turn that ended in a recoverable agent
|
|
// failure. The failure's recovery/error entry is the turn's outcome, so a turn
|
|
// carrying this marker reports had_output=true at completion even though a
|
|
// status/recovery message does not otherwise count as agent output — this
|
|
// keeps the frontend from showing a spurious empty-turn notice for the failed
|
|
// turn.
|
|
const TurnMetaKeyErrorTerminated = "error_terminated"
|
|
|
|
// TurnMetaKeyPromptDispatchPending marks a successor created before agentctl
|
|
// acknowledges its prompt. Empty marked turns are not current-turn authority
|
|
// unless dispatch ambiguity was recorded; publication clears the marker, while
|
|
// a referencing message also proves ambiguous acceptance after a crash.
|
|
const TurnMetaKeyPromptDispatchPending = "prompt_dispatch_pending"
|
|
|
|
const (
|
|
TurnMetaKeyPromptDispatchAttempted = "prompt_dispatch_attempted"
|
|
TurnMetaKeyPromptDispatchClarificationPendingID = "prompt_dispatch_clarification_pending_id"
|
|
TurnMetaKeyPromptDispatchClarificationTurnID = "prompt_dispatch_clarification_turn_id"
|
|
TurnMetaKeyPromptDispatchClarificationMessageIDs = "prompt_dispatch_clarification_message_ids"
|
|
// TurnMetaKeyPromptDispatchStartEventPending is a durable outbox marker.
|
|
// Startup recovery sets it after accepting an ambiguous reservation and
|
|
// clears it only after the task service replays the public turn events.
|
|
TurnMetaKeyPromptDispatchStartEventPending = "prompt_dispatch_start_event_pending"
|
|
)
|
|
|
|
var promptDispatchMetadataKeys = [...]string{
|
|
TurnMetaKeyPromptDispatchPending,
|
|
TurnMetaKeyPromptDispatchAttempted,
|
|
TurnMetaKeyPromptDispatchClarificationPendingID,
|
|
TurnMetaKeyPromptDispatchClarificationTurnID,
|
|
TurnMetaKeyPromptDispatchClarificationMessageIDs,
|
|
TurnMetaKeyPromptDispatchStartEventPending,
|
|
}
|
|
|
|
// ClearPromptDispatchMetadata removes every reservation-only field after live
|
|
// publication or successful startup event replay.
|
|
func ClearPromptDispatchMetadata(metadata map[string]interface{}) {
|
|
for _, key := range promptDispatchMetadataKeys {
|
|
delete(metadata, key)
|
|
}
|
|
}
|
|
|
|
// publicTurnMetadataKeys is the allowlist for first-party live turn
|
|
// projections. Prompt-dispatch recovery state and arbitrary repository-owned
|
|
// fields stay inside the service and are not sent through the shared event
|
|
// bus. The REST turn DTO has its own compatibility contract; this narrower
|
|
// projection is used only for the v2 Host conversation transport.
|
|
var publicTurnMetadataKeys = [...]string{
|
|
TurnMetaKeyRuntimeConfigSnapshot,
|
|
TurnMetaKeyWorkflowStepIDAtStart,
|
|
TurnMetaKeyLifecycleOnly,
|
|
"prompt_usage",
|
|
"model",
|
|
"agent_id",
|
|
"agent_type",
|
|
"usage_multiplier",
|
|
}
|
|
|
|
// ProjectTurnMetadata returns the first-party-safe metadata needed by live
|
|
// core conversation consumers without exposing arbitrary turn state.
|
|
func ProjectTurnMetadata(metadata map[string]interface{}) map[string]interface{} {
|
|
if len(metadata) == 0 {
|
|
return nil
|
|
}
|
|
projected := make(map[string]interface{}, len(publicTurnMetadataKeys))
|
|
for _, key := range publicTurnMetadataKeys {
|
|
if value, ok := metadata[key]; ok {
|
|
projected[key] = value
|
|
}
|
|
}
|
|
ClearPromptDispatchMetadata(projected)
|
|
if len(projected) == 0 {
|
|
return nil
|
|
}
|
|
return projected
|
|
}
|
|
|
|
// PromptDispatchRecovery identifies the exact clarification claim that an
|
|
// unpublished successor reservation must restore after a backend restart.
|
|
type PromptDispatchRecovery struct {
|
|
PendingID string
|
|
TurnID string
|
|
MessageIDs []string
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// LoadInitialSessionRuntimeConfig decodes the task launch seed from typed or
|
|
// JSON-rehydrated task metadata.
|
|
func LoadInitialSessionRuntimeConfig(metadata map[string]interface{}) (SessionRuntimeConfig, bool) {
|
|
return loadSessionRuntimeConfig(metadata, MetaKeyInitialSessionRuntimeConfig)
|
|
}
|
|
|
|
// LoadInitialSessionRuntimeConfigProfileID returns the profile that owns the
|
|
// launch-only runtime seed. Older tasks did not store a separate owner, so
|
|
// their resolved task profile remains a compatibility fallback.
|
|
func LoadInitialSessionRuntimeConfigProfileID(metadata map[string]interface{}) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
if profileID := StringFromAny(metadata[MetaKeyInitialSessionRuntimeConfigProfileID]); profileID != "" {
|
|
return profileID
|
|
}
|
|
return StringFromAny(metadata[MetaKeyAgentProfileID])
|
|
}
|
|
|
|
// LoadEffectiveSessionRuntimeConfig resolves the effective model, mode, and
|
|
// dynamic options for a task session. The profile snapshot is the base, the
|
|
// provider runtime state replaces it, the persisted session mode takes
|
|
// precedence over provider mode, and explicit runtime overrides win last.
|
|
func LoadEffectiveSessionRuntimeConfig(session *TaskSession) (SessionRuntimeConfig, bool) {
|
|
if session == nil {
|
|
return SessionRuntimeConfig{}, false
|
|
}
|
|
effective := runtimeConfigFromAgentProfileSnapshot(session.AgentProfileSnapshot)
|
|
if runtime, ok := LoadSessionRuntimeConfig(session.Metadata); ok {
|
|
mergeSessionRuntimeConfig(&effective, runtime)
|
|
if runtime.ConfigOptions != nil {
|
|
// Provider runtime options are a complete replacement for profile
|
|
// options. Explicit overrides below are the only later merge.
|
|
effective.ConfigOptions = maps.Clone(runtime.ConfigOptions)
|
|
}
|
|
}
|
|
if mode := StringFromAny(session.Metadata[SessionMetaKeySessionMode]); mode != "" {
|
|
effective.Mode = mode
|
|
}
|
|
if overrides, ok := LoadSessionRuntimeConfigOverrides(session.Metadata); ok {
|
|
mergeSessionRuntimeConfig(&effective, overrides)
|
|
}
|
|
effective.ConfigOptions = cleanRuntimeConfigOptions(effective.ConfigOptions, session)
|
|
return effective, !effective.IsZero()
|
|
}
|
|
|
|
func runtimeConfigFromAgentProfileSnapshot(snapshot map[string]interface{}) SessionRuntimeConfig {
|
|
if snapshot == nil {
|
|
return SessionRuntimeConfig{}
|
|
}
|
|
config := SessionRuntimeConfig{
|
|
Model: StringFromAny(snapshot["model"]),
|
|
Mode: StringFromAny(snapshot["mode"]),
|
|
}
|
|
config.ConfigOptions = stringMapFromAny(snapshot["config_options"])
|
|
if config.ConfigOptions == nil {
|
|
config.ConfigOptions = stringMapFromAny(snapshot["configOptions"])
|
|
}
|
|
return config
|
|
}
|
|
|
|
func mergeSessionRuntimeConfig(target *SessionRuntimeConfig, source SessionRuntimeConfig) {
|
|
if source.Model != "" {
|
|
target.Model = source.Model
|
|
}
|
|
if source.Mode != "" {
|
|
target.Mode = source.Mode
|
|
}
|
|
if len(source.ConfigOptions) == 0 {
|
|
return
|
|
}
|
|
if target.ConfigOptions == nil {
|
|
target.ConfigOptions = make(map[string]string, len(source.ConfigOptions))
|
|
}
|
|
for key, value := range source.ConfigOptions {
|
|
target.ConfigOptions[key] = value
|
|
}
|
|
}
|
|
|
|
func cleanRuntimeConfigOptions(options map[string]string, session *TaskSession) map[string]string {
|
|
if len(options) == 0 {
|
|
return nil
|
|
}
|
|
cleaned := maps.Clone(options)
|
|
// Model and mode are carried as top-level fields. Other keys are provider-defined.
|
|
delete(cleaned, "model")
|
|
delete(cleaned, "mode")
|
|
if isLegacyAgentIdentity(session, cleaned["agent"]) {
|
|
delete(cleaned, "agent")
|
|
}
|
|
if len(cleaned) == 0 {
|
|
return nil
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func isLegacyAgentIdentity(session *TaskSession, value string) bool {
|
|
if session == nil || value == "" || session.AgentProfileSnapshot == nil {
|
|
return false
|
|
}
|
|
for _, key := range []string{"agent_id", "agent_name"} {
|
|
if StringFromAny(session.AgentProfileSnapshot[key]) == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SessionOriginalEffectiveConfiguration is the immutable configuration a task
|
|
// session had after provider defaults and its profile settings settled.
|
|
type SessionOriginalEffectiveConfiguration struct {
|
|
Model string `json:"model,omitempty"`
|
|
ConfigOptions map[string]string `json:"config_options,omitempty"`
|
|
}
|
|
|
|
// LoadOriginalSessionEffectiveConfiguration decodes the original configuration
|
|
// from typed or JSON-rehydrated session metadata.
|
|
func LoadOriginalSessionEffectiveConfiguration(metadata map[string]interface{}) (SessionOriginalEffectiveConfiguration, bool) {
|
|
if metadata == nil {
|
|
return SessionOriginalEffectiveConfiguration{}, false
|
|
}
|
|
raw, ok := metadata[SessionMetaKeyOriginalEffectiveConfig]
|
|
if !ok || raw == nil {
|
|
return SessionOriginalEffectiveConfiguration{}, false
|
|
}
|
|
if config, ok := raw.(SessionOriginalEffectiveConfiguration); ok {
|
|
config.ConfigOptions = maps.Clone(config.ConfigOptions)
|
|
return config, config.Model != "" || len(config.ConfigOptions) > 0
|
|
}
|
|
data, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return SessionOriginalEffectiveConfiguration{}, false
|
|
}
|
|
var config SessionOriginalEffectiveConfiguration
|
|
if err := json.Unmarshal(data, &config); err != nil {
|
|
return SessionOriginalEffectiveConfiguration{}, false
|
|
}
|
|
return config, config.Model != "" || len(config.ConfigOptions) > 0
|
|
}
|
|
|
|
// IsOriginalTaskSession reports whether immutable task-session provenance marks
|
|
// this row as the session created when the task first started.
|
|
func IsOriginalTaskSession(metadata map[string]interface{}) bool {
|
|
if metadata == nil {
|
|
return false
|
|
}
|
|
return StringFromAny(metadata[SessionMetaKeyOrigin]) == SessionOriginTaskInitial
|
|
}
|
|
|
|
// 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].
|
|
// RemediationURL is only ever set from an adapter-validated provider
|
|
// diagnostic; it is never reconstructed from the error message.
|
|
type LastAgentError struct {
|
|
Message string `json:"message"`
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
Scope string `json:"scope,omitempty"`
|
|
AgentExecutionID string `json:"agent_execution_id,omitempty"`
|
|
ExecutionID string `json:"execution_id,omitempty"`
|
|
Phase string `json:"phase,omitempty"`
|
|
AttemptID string `json:"attempt_id,omitempty"`
|
|
Causes []AgentErrorCause `json:"causes,omitempty"`
|
|
RemediationURL string `json:"remediation_url,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
Details string `json:"details,omitempty"`
|
|
RecoveryActions []string `json:"recovery_actions,omitempty"`
|
|
TaskRepositoryID string `json:"task_repository_id,omitempty"`
|
|
StampValue string `json:"stamp,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 normalizeLastAgentError(out), true
|
|
}
|
|
|
|
func mapToLastAgentError(raw interface{}, out *LastAgentError) error {
|
|
data, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Optional bootstrap fields are deliberately decoded independently. A
|
|
// malformed optional field must not hide a valid legacy session error.
|
|
optional := map[string]json.RawMessage{}
|
|
for _, key := range []string{"execution_id", "phase", "attempt_id", "causes"} {
|
|
if value, ok := fields[key]; ok {
|
|
optional[key] = value
|
|
delete(fields, key)
|
|
}
|
|
}
|
|
legacyData, err := json.Marshal(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(legacyData, out); err != nil {
|
|
return err
|
|
}
|
|
if value, ok := optional["execution_id"]; ok {
|
|
var executionID string
|
|
if json.Unmarshal(value, &executionID) == nil {
|
|
out.ExecutionID = executionID
|
|
}
|
|
}
|
|
if value, ok := optional["phase"]; ok {
|
|
var phase string
|
|
if json.Unmarshal(value, &phase) == nil {
|
|
out.Phase = phase
|
|
}
|
|
}
|
|
if value, ok := optional["attempt_id"]; ok {
|
|
var attemptID string
|
|
if json.Unmarshal(value, &attemptID) == nil {
|
|
out.AttemptID = attemptID
|
|
}
|
|
}
|
|
if value, ok := optional["causes"]; ok {
|
|
var causes []AgentErrorCause
|
|
if json.Unmarshal(value, &causes) == nil {
|
|
out.Causes = causes
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e LastAgentError) Stamp() string {
|
|
if stamp := boundedLaunchErrorStamp(e.StampValue); stamp != "" {
|
|
return stamp
|
|
}
|
|
return e.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + e.Message
|
|
}
|
|
|
|
func (e LastAgentError) MatchesStamp(stamp string) bool {
|
|
if stamp == e.Stamp() {
|
|
return true
|
|
}
|
|
if boundedLaunchErrorStamp(e.StampValue) != "" {
|
|
return false
|
|
}
|
|
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:
|
|
v.ConfigOptions = maps.Clone(v.ConfigOptions)
|
|
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"
|
|
// TaskOriginAutomationTask is a normal, user-visible task created by an
|
|
// automation. Unlike automation_run, it remains in Kanban/sidebar flows.
|
|
TaskOriginAutomationTask = "automation_task"
|
|
)
|
|
|
|
// 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"`
|
|
// WorkflowAgentOverrides is scoped to WorkflowID and expands the grouped
|
|
// create choice into fixed step bindings. It is nil for ordinary tasks.
|
|
WorkflowAgentOverrides *WorkflowAgentOverrides `json:"workflow_agent_overrides,omitempty"`
|
|
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
|
|
// WIPAdmitted indicates whether this task consumes an active slot in its
|
|
// current workflow step. Queued tasks remain visible but do not consume the
|
|
// destination step's WIP capacity.
|
|
WIPAdmitted bool `json:"wip_admitted"`
|
|
QueuedForStepID string `json:"queued_for_step_id,omitempty"`
|
|
QueuedAt *time.Time `json:"queued_at,omitempty"`
|
|
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
|
|
Autopilot bool `json:"autopilot"` // Fixed at task creation
|
|
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"`
|
|
// WorkflowStepTransitionID is the immutable ledger row identifier for the
|
|
// latest workflow-step write. Repositories populate it after a transition;
|
|
// it is transient and is not stored in the tasks table.
|
|
WorkflowStepTransitionID int64 `json:"-"`
|
|
// FromStepID is the workflow_step_id the task left on the same write that
|
|
// set WorkflowStepTransitionID, read inside that write's own transaction
|
|
// (readTaskStepInTx) rather than from any earlier snapshot. Empty when no
|
|
// transition occurred (WorkflowStepTransitionID == 0) or on task creation.
|
|
// Transient and is not stored in the tasks table.
|
|
FromStepID string `json:"-"`
|
|
// FromWorkflowID is the workflow_id the task left on the same write that
|
|
// set WorkflowStepTransitionID, read inside that write's own transaction.
|
|
// It is transient and is not stored in the tasks table.
|
|
FromWorkflowID string `json:"-"`
|
|
|
|
// 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"`
|
|
// AssigneeUserID is the HUMAN assignee, entirely independent of the agent
|
|
// assignee above: a task can carry both, and setting one never clears the
|
|
// other. It is advisory and gates nothing; taking a task over is a
|
|
// reassignment plus a prompt, not a lock.
|
|
AssigneeUserID string `json:"assignee_user_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"
|
|
|
|
// ExternalID is a caller-supplied identity used for create-idempotency
|
|
// (docs/specs/tasks/requirements/external-id-idempotency.md). Empty when the task
|
|
// holds none. Unique per (workspace_id, external_id) when non-empty.
|
|
ExternalID string `json:"external_id,omitempty"`
|
|
// ExternalIDSettledAt is non-nil once the create that claimed ExternalID
|
|
// finished its required synchronous work. Nil means unsettled — the
|
|
// claiming create may still be in progress. Both fields are cleared
|
|
// together on release.
|
|
ExternalIDSettledAt *time.Time `json:"external_id_settled_at,omitempty"`
|
|
|
|
// 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 != ""
|
|
}
|
|
|
|
// OfficeDecisionWaitCandidate is the compact projection the Office
|
|
// decision-waiting detector scans (REQ-OFFICE-STALL-VISIBILITY-002). It is a
|
|
// candidate, not a finding: the repository query only establishes that the
|
|
// task is Office-owned, sits at a step carrying a decision-required seat, and
|
|
// has been quiet since UpdatedAt. Whether a decision was already recorded and
|
|
// whether a run is still in flight are judged by the detector, so each
|
|
// rejection has its own countable reason.
|
|
type OfficeDecisionWaitCandidate struct {
|
|
TaskID string `db:"id"`
|
|
StepID string `db:"workflow_step_id"`
|
|
UpdatedAt time.Time `db:"updated_at"`
|
|
}
|
|
|
|
// OfficeDecisionWaitCursor identifies the last candidate in one ordered page.
|
|
// The task repository uses it to continue a bounded scan without repeatedly
|
|
// returning the same oldest rows.
|
|
type OfficeDecisionWaitCursor struct {
|
|
UpdatedAt time.Time
|
|
TaskID string
|
|
}
|
|
|
|
// 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"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
// HasStartWhenUnblockedIntent reports whether a task's deferred launch intent
|
|
// belongs to a dependency chain rather than to WIP overflow.
|
|
//
|
|
// The two share one record; DeferredLaunchStartWhenUnblockedKey is what tells
|
|
// them apart. Defined here, next to the key, so the DTO layer, the task service
|
|
// and the orchestrator cannot drift on what counts as a chain intent.
|
|
func HasStartWhenUnblockedIntent(task *Task) bool {
|
|
if task == nil || task.Metadata == nil {
|
|
return false
|
|
}
|
|
raw, ok := task.Metadata[MetaKeyDeferredLaunch]
|
|
if !ok {
|
|
return false
|
|
}
|
|
launch, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return false
|
|
}
|
|
flag, ok := launch[DeferredLaunchStartWhenUnblockedKey].(bool)
|
|
return ok && flag
|
|
}
|
|
|
|
// DropWIPDeferredLaunch removes a deferred launch record that only existed
|
|
// because the task was waiting on WIP capacity.
|
|
//
|
|
// Every WIP admission point calls this instead of deleting the key outright.
|
|
// An admitted task has nothing left to wait for as far as WIP is concerned, so
|
|
// its overflow record is stale. A start-when-unblocked intent is NOT stale in
|
|
// that situation: it is waiting on a predecessor, not on capacity, and the two
|
|
// share this one metadata record. Deleting it on admission silently unmakes
|
|
// every dependency chain, because admission happens at create time for any task
|
|
// entering a step with room.
|
|
func DropWIPDeferredLaunch(task *Task) {
|
|
if task == nil || task.Metadata == nil || HasStartWhenUnblockedIntent(task) || HasCeilingDeferredIntent(task) {
|
|
return
|
|
}
|
|
delete(task.Metadata, MetaKeyDeferredLaunch)
|
|
}
|
|
|
|
// 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"
|
|
)
|
|
|
|
// WorkflowProfileSessionStartPolicy controls how a fixed-profile workflow step
|
|
// obtains a session when it starts after a profile switch.
|
|
type WorkflowProfileSessionStartPolicy string
|
|
|
|
const (
|
|
WorkflowProfileSessionStartPolicyReuse WorkflowProfileSessionStartPolicy = "reuse"
|
|
WorkflowProfileSessionStartPolicyNew WorkflowProfileSessionStartPolicy = "new"
|
|
)
|
|
|
|
// NormalizeWorkflowProfileSessionStartPolicy returns the safe default for empty
|
|
// and unknown workflow step session-start policy values.
|
|
func NormalizeWorkflowProfileSessionStartPolicy(value string) WorkflowProfileSessionStartPolicy {
|
|
value = strings.TrimSpace(value)
|
|
if WorkflowProfileSessionStartPolicy(value) == WorkflowProfileSessionStartPolicyNew {
|
|
return WorkflowProfileSessionStartPolicyNew
|
|
}
|
|
return WorkflowProfileSessionStartPolicyReuse
|
|
}
|
|
|
|
// WorkflowProfileSessionEndPolicy controls what happens to a fixed-profile
|
|
// workflow step's session when the workflow leaves it for another profile.
|
|
type WorkflowProfileSessionEndPolicy string
|
|
|
|
const (
|
|
WorkflowProfileSessionEndPolicyComplete WorkflowProfileSessionEndPolicy = "complete"
|
|
WorkflowProfileSessionEndPolicyPark WorkflowProfileSessionEndPolicy = "park"
|
|
)
|
|
|
|
// NormalizeWorkflowProfileSessionEndPolicy returns the safe default for empty
|
|
// and unknown workflow step session-end policy values.
|
|
func NormalizeWorkflowProfileSessionEndPolicy(value string) WorkflowProfileSessionEndPolicy {
|
|
value = strings.TrimSpace(value)
|
|
if WorkflowProfileSessionEndPolicy(value) == WorkflowProfileSessionEndPolicyComplete {
|
|
return WorkflowProfileSessionEndPolicyComplete
|
|
}
|
|
return WorkflowProfileSessionEndPolicyPark
|
|
}
|
|
|
|
// 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"`
|
|
// Prompt is optional workflow-level agent instructions prepended at
|
|
// every step entry before the step prompt. Empty means off.
|
|
Prompt string `json:"prompt,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
// WorkspaceNameImproveKandev is the exact name of the dedicated Improve Kandev
|
|
// workspace created by the improve-kandev bootstrap. The workspace is matched
|
|
// by this name everywhere (bootstrap, guards, frontend) and is
|
|
// configuration-immutable: its workflows and repositories are read-only.
|
|
const WorkspaceNameImproveKandev = "Improve Kandev"
|
|
|
|
// IsImproveKandev reports whether the workspace is the dedicated Improve
|
|
// Kandev workspace (matched by exact name).
|
|
func (w *Workspace) IsImproveKandev() bool {
|
|
return w != nil && w.Name == WorkspaceNameImproveKandev
|
|
}
|
|
|
|
// Workspace represents a workspace
|
|
type Workspace struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
OwnerID string `json:"owner_id"`
|
|
// OrgID is the owning tenant. Empty means organizations are off or the
|
|
// tenancy migration has not run.
|
|
OrgID string `json:"org_id,omitempty"`
|
|
// UnitID places the workspace in the organization unit tree. Reach is
|
|
// resolved from this placement, so a workspace without one is reachable by
|
|
// nobody.
|
|
UnitID string `json:"unit_id,omitempty"`
|
|
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"`
|
|
BranchPolicyID string `json:"branch_policy_id,omitempty"`
|
|
BranchPolicyName string `json:"branch_policy_name,omitempty"`
|
|
BranchPolicyBaseBranch string `json:"branch_policy_base_branch,omitempty"`
|
|
BranchPolicyBranchTemplate string `json:"branch_policy_branch_template,omitempty"`
|
|
BranchPolicyPullRequestTarget string `json:"branch_policy_pull_request_target,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"`
|
|
ExpectedParentID string `json:"-"`
|
|
ExpectedParentWorkspaceID string `json:"-"`
|
|
}
|
|
|
|
// 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"
|
|
// MessageTypeToolSearch is for code and file search operations
|
|
MessageTypeToolSearch MessageType = "tool_search"
|
|
// 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"
|
|
)
|
|
|
|
type PermissionResolutionActorKind string
|
|
|
|
const (
|
|
PermissionActorBrowser PermissionResolutionActorKind = "browser"
|
|
PermissionActorPersonalAccessToken PermissionResolutionActorKind = "personal_access_token"
|
|
PermissionActorAutomation PermissionResolutionActorKind = "automation"
|
|
PermissionActorSynthetic PermissionResolutionActorKind = "synthetic"
|
|
)
|
|
|
|
type PermissionResolutionSource string
|
|
|
|
const (
|
|
PermissionSourceWeb PermissionResolutionSource = "web"
|
|
PermissionSourceExternalMCP PermissionResolutionSource = "external_mcp"
|
|
PermissionSourceAutomation PermissionResolutionSource = "automation"
|
|
// PermissionSourceAutomationMCP identifies a resolution made by the
|
|
// fixed in-session coordinator surface. It is distinct from legacy
|
|
// backend automation and from the authenticated external MCP bridge.
|
|
PermissionSourceAutomationMCP PermissionResolutionSource = "automation_mcp"
|
|
)
|
|
|
|
type PermissionResolutionResult string
|
|
|
|
const (
|
|
PermissionResolutionDispatching PermissionResolutionResult = "dispatching"
|
|
PermissionResolutionAccepted PermissionResolutionResult = "accepted"
|
|
PermissionResolutionStale PermissionResolutionResult = "stale"
|
|
PermissionResolutionExpired PermissionResolutionResult = "expired"
|
|
PermissionResolutionFailed PermissionResolutionResult = "failed"
|
|
PermissionResolutionIndeterminate PermissionResolutionResult = "indeterminate"
|
|
)
|
|
|
|
// PermissionResolutionAudit is the durable, presentation-free record of one
|
|
// exact resolution attempt. Credential-bearing action data is not part of this
|
|
// type by design.
|
|
type PermissionResolutionAudit struct {
|
|
ClaimID string `json:"claim_id"`
|
|
ActorUserID string `json:"actor_user_id,omitempty"`
|
|
ActorKind PermissionResolutionActorKind `json:"actor_kind"`
|
|
Source PermissionResolutionSource `json:"source"`
|
|
RequestID string `json:"request_id"`
|
|
PendingID string `json:"pending_id"`
|
|
OptionID string `json:"option_id"`
|
|
OptionKind string `json:"option_kind"`
|
|
SelectedAt time.Time `json:"selected_at"`
|
|
FinalizedAt *time.Time `json:"finalized_at,omitempty"`
|
|
Result PermissionResolutionResult `json:"result"`
|
|
}
|
|
|
|
type PermissionResolutionClaimOutcome string
|
|
|
|
const (
|
|
PermissionClaimed PermissionResolutionClaimOutcome = "claimed"
|
|
PermissionClaimNotFound PermissionResolutionClaimOutcome = "not_found"
|
|
PermissionClaimInProgress PermissionResolutionClaimOutcome = "in_progress"
|
|
PermissionClaimAlreadyFinal PermissionResolutionClaimOutcome = "already_final"
|
|
)
|
|
|
|
type PermissionResolutionClaimRequest struct {
|
|
TaskID string
|
|
SessionID string
|
|
Audit PermissionResolutionAudit
|
|
}
|
|
|
|
type PermissionResolutionClaimResult struct {
|
|
Outcome PermissionResolutionClaimOutcome
|
|
Message *Message
|
|
}
|
|
|
|
type PermissionResolutionFinalizeOutcome string
|
|
|
|
const (
|
|
PermissionFinalized PermissionResolutionFinalizeOutcome = "finalized"
|
|
PermissionFinalizeNotFound PermissionResolutionFinalizeOutcome = "not_found"
|
|
PermissionFinalizeClaimMismatch PermissionResolutionFinalizeOutcome = "claim_mismatch"
|
|
PermissionFinalizeAlreadyFinal PermissionResolutionFinalizeOutcome = "already_final"
|
|
)
|
|
|
|
type PermissionResolutionFinalizeRequest struct {
|
|
TaskID string
|
|
SessionID string
|
|
RequestID string
|
|
PendingID string
|
|
ClaimID string
|
|
Result PermissionResolutionResult
|
|
Status PermissionStatus
|
|
FinalizedAt time.Time
|
|
}
|
|
|
|
type PermissionResolutionFinalizeResult struct {
|
|
Outcome PermissionResolutionFinalizeOutcome
|
|
Message *Message
|
|
}
|
|
|
|
// TaskPendingAction is the compact task-list projection for a session blocked
|
|
// on user input.
|
|
type TaskPendingAction string
|
|
|
|
// PendingActionRevision is a logical clock shared by REST and WebSocket
|
|
// pending-action projections. Epoch is a durable, monotonically allocated
|
|
// backend generation; Sequence orders snapshots within that generation.
|
|
type PendingActionRevision struct {
|
|
Epoch string `json:"epoch"`
|
|
Sequence uint64 `json:"sequence"`
|
|
}
|
|
|
|
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
|
|
// PromptIndex is the 1-based ordinal of the message among ALL user
|
|
// messages of its session, ordered by normalized-microsecond created_at
|
|
// ascending with ties broken by id ascending. Read-time derived: zero for
|
|
// non-user messages and for legacy 12-column reads, and serialized only
|
|
// when > 0 (json omitempty).
|
|
PromptIndex int `json:"prompt_index,omitempty"`
|
|
}
|
|
|
|
// 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,
|
|
PromptIndex: m.PromptIndex,
|
|
}
|
|
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"`
|
|
// ExecutionProfileID and RouteGeneration are immutable attribution for the
|
|
// concrete route that started this turn. They are kept alongside the
|
|
// existing metadata during the compatibility migration so old rows remain
|
|
// readable.
|
|
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
|
|
RouteGeneration int64 `json:"route_generation,omitempty"`
|
|
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
|
|
|
|
// PromptableTaskSessionClaimStatus describes the outcome of atomically
|
|
// reserving a session for a lifecycle prompt.
|
|
type PromptableTaskSessionClaimStatus string
|
|
|
|
const (
|
|
PromptableTaskSessionClaimed PromptableTaskSessionClaimStatus = "claimed"
|
|
PromptableTaskSessionBusy PromptableTaskSessionClaimStatus = "busy"
|
|
PromptableTaskSessionInactive PromptableTaskSessionClaimStatus = "inactive"
|
|
)
|
|
|
|
// PromptableTaskSessionClaim is the result of a lifecycle prompt reservation.
|
|
// PreviousState is set only when Status is PromptableTaskSessionClaimed.
|
|
type PromptableTaskSessionClaim struct {
|
|
Status PromptableTaskSessionClaimStatus
|
|
PreviousState TaskSessionState
|
|
}
|
|
|
|
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"
|
|
)
|
|
|
|
// AllTaskSessionStates is the canonical, exhaustive list of TaskSessionState
|
|
// constants. Code that must cover every state (drift-guard tests, admin
|
|
// tooling) should range over this slice instead of hand-writing its own
|
|
// literal: Go does not enforce switch/slice exhaustiveness (the exhaustive
|
|
// linter is not enabled in this repo), so a hand-written literal silently
|
|
// stops covering new states the moment one is added here. Add a new
|
|
// TaskSessionState constant to this slice in the same change that adds the
|
|
// const.
|
|
var AllTaskSessionStates = []TaskSessionState{
|
|
TaskSessionStateCreated,
|
|
TaskSessionStateStarting,
|
|
TaskSessionStateRunning,
|
|
TaskSessionStateIdle,
|
|
TaskSessionStateWaitingForInput,
|
|
TaskSessionStateCompleted,
|
|
TaskSessionStateFailed,
|
|
TaskSessionStateCancelled,
|
|
}
|
|
|
|
// 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"`
|
|
QueueIncarnationID string `json:"queue_incarnation_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
|
|
RouteGeneration int64 `json:"route_generation,omitempty"`
|
|
RouteState string `json:"route_state,omitempty"`
|
|
RouteReason string `json:"route_reason,omitempty"`
|
|
DownstreamACPSessionID string `json:"downstream_acp_session_id,omitempty"`
|
|
RouteErrorCode string `json:"route_error_code,omitempty"`
|
|
RouteErrorClass string `json:"route_error_class,omitempty"`
|
|
RouteCatalogueVersion string `json:"route_catalogue_version,omitempty"`
|
|
RouteRetryOrdinal int64 `json:"route_retry_ordinal,omitempty"`
|
|
RouteDeadline *time.Time `json:"route_deadline,omitempty"`
|
|
RoutePendingOutcome string `json:"route_pending_outcome,omitempty"`
|
|
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"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder
|
|
Worktrees []*TaskEnvironmentRepo `json:"-"` // Environment repository rows for this session's workspace
|
|
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
|
|
|
|
// TaskRunnerResolvedFromTask marks a session whose executor profile came
|
|
// from task metadata rather than an explicit launch argument. It is
|
|
// transient and lets the persistence transaction reject a stale
|
|
// pre-lock resolution before inserting the session or environment.
|
|
TaskRunnerResolvedFromTask bool `json:"-"`
|
|
// TaskRunnerProfileAtResolution is the task metadata value observed when
|
|
// the session's runner was resolved. It is transient and paired with
|
|
// TaskRunnerResolvedFromTask for the persistence-time recheck.
|
|
TaskRunnerProfileAtResolution string `json:"-"`
|
|
|
|
// 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
|
|
// LastReadMessageID is the id of the newest message the frontend has
|
|
// marked as read for this session — the Slack-style "read cursor" used
|
|
// to position the unread ("New") divider in the transcript. Advanced to
|
|
// the latest message id whenever the session becomes the visible chat
|
|
// panel; the frontend snapshots the PRIOR value at that moment to draw
|
|
// the divider before overwriting it.
|
|
LastReadMessageID string `json:"last_read_message_id,omitempty"`
|
|
|
|
// Usage/cost rollup columns (docs/specs/task-cost-ledger/spec.md AC-28,
|
|
// AC-29). task_usage_events is the source of truth; these are the
|
|
// running totals internal/task/usage's writer maintains transactionally
|
|
// alongside each ledger insert via IncrementTaskSessionUsageTx.
|
|
CostSubcents int64 `json:"cost_subcents"`
|
|
TokensIn int64 `json:"tokens_in"`
|
|
TokensCachedIn int64 `json:"tokens_cached_in"`
|
|
TokensOut int64 `json:"tokens_out"`
|
|
}
|
|
|
|
// ActiveSessionCancellationCandidate is the compare-and-set snapshot used by
|
|
// the active-session stall healer. An empty ExpectedTurnID means that no turn
|
|
// may be active when the cancellation is written.
|
|
type ActiveSessionCancellationCandidate struct {
|
|
SessionID string
|
|
ExpectedUpdatedAt time.Time
|
|
ExpectedLastEventAt time.Time
|
|
ExpectedTurnID string
|
|
}
|
|
|
|
// 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,
|
|
"queue_incarnation_id": s.QueueIncarnationID,
|
|
"agent_execution_id": s.AgentExecutionID,
|
|
"container_id": s.ContainerID,
|
|
"agent_profile_id": s.AgentProfileID,
|
|
"execution_profile_id": s.ExecutionProfileID,
|
|
"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,
|
|
"state": string(s.State),
|
|
"started_at": s.StartedAt,
|
|
"updated_at": s.UpdatedAt,
|
|
}
|
|
if s.RouteGeneration > 0 {
|
|
result["route_generation"] = s.RouteGeneration
|
|
}
|
|
if s.RouteState != "" {
|
|
result["route_state"] = s.RouteState
|
|
}
|
|
if s.RouteReason != "" {
|
|
result["route_reason"] = s.RouteReason
|
|
}
|
|
if s.DownstreamACPSessionID != "" {
|
|
result["downstream_acp_session_id"] = s.DownstreamACPSessionID
|
|
}
|
|
if s.RouteErrorCode != "" {
|
|
result["route_error_code"] = s.RouteErrorCode
|
|
}
|
|
if s.RouteErrorClass != "" {
|
|
result["route_error_class"] = s.RouteErrorClass
|
|
}
|
|
if s.RouteCatalogueVersion != "" {
|
|
result["route_catalogue_version"] = s.RouteCatalogueVersion
|
|
}
|
|
if s.RouteRetryOrdinal > 0 {
|
|
result["route_retry_ordinal"] = s.RouteRetryOrdinal
|
|
}
|
|
if s.RouteDeadline != nil {
|
|
result["route_deadline"] = s.RouteDeadline
|
|
}
|
|
if s.RoutePendingOutcome != "" {
|
|
result["route_pending_outcome"] = s.RoutePendingOutcome
|
|
}
|
|
if worktrees := s.WorktreesAPI(); len(worktrees) > 0 {
|
|
result["worktrees"] = worktrees
|
|
}
|
|
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
|
|
}
|
|
if s.LastReadMessageID != "" {
|
|
result["last_read_message_id"] = s.LastReadMessageID
|
|
}
|
|
return result
|
|
}
|
|
|
|
// WorktreesAPI maps the session's environment repository rows to the legacy
|
|
// session-worktree API shape. The wire contract is stable: the frontend reads
|
|
// id, worktree_id, repository_id, branch_slug, position, worktree_path, and
|
|
// worktree_branch from each entry, and worktree_path/worktree_branch are
|
|
// mirrored onto the session for backward compatibility.
|
|
func (s *TaskSession) WorktreesAPI() []map[string]interface{} {
|
|
if len(s.Worktrees) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(s.Worktrees))
|
|
for _, repo := range s.Worktrees {
|
|
if repo == nil {
|
|
continue
|
|
}
|
|
entry := map[string]interface{}{
|
|
"id": repo.ID,
|
|
"session_id": s.ID,
|
|
"worktree_id": repo.WorktreeID,
|
|
"repository_id": repo.RepositoryID,
|
|
"position": repo.Position,
|
|
createdAtField: repo.CreatedAt,
|
|
}
|
|
if repo.BranchSlug != "" {
|
|
entry["branch_slug"] = repo.BranchSlug
|
|
}
|
|
if repo.WorktreePath != "" {
|
|
entry["worktree_path"] = repo.WorktreePath
|
|
}
|
|
if repo.WorktreeBranch != "" {
|
|
entry["worktree_branch"] = repo.WorktreeBranch
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// WorktreePath returns the workspace path of the session's first repository
|
|
// row, used for backward-compatible projections.
|
|
func (s *TaskSession) WorktreePath() string {
|
|
if len(s.Worktrees) > 0 && s.Worktrees[0] != nil {
|
|
return s.Worktrees[0].WorktreePath
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WorktreeBranch returns the branch of the session's first repository row,
|
|
// used for backward-compatible projections.
|
|
func (s *TaskSession) WorktreeBranch() string {
|
|
if len(s.Worktrees) > 0 && s.Worktrees[0] != nil {
|
|
return s.Worktrees[0].WorktreeBranch
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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"`
|
|
ProviderScope string `json:"provider_scope"`
|
|
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"`
|
|
SecretBindings []RepositorySecretBinding `json:"secret_bindings,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
// DesktopDiscoveryRootState describes whether an install-wide discovery root
|
|
// can currently be read by the backend process.
|
|
type DesktopDiscoveryRootState string
|
|
|
|
const (
|
|
DesktopDiscoveryRootConnected DesktopDiscoveryRootState = "connected"
|
|
DesktopDiscoveryRootReconnectRequired DesktopDiscoveryRootState = "reconnect_required"
|
|
)
|
|
|
|
// DesktopDiscoveryRoot is an install-wide root selected for automatic local
|
|
// repository discovery. It is deliberately separate from a workspace-owned
|
|
// repository grant.
|
|
type DesktopDiscoveryRoot struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
DisplayPath string `json:"display_path"`
|
|
State DesktopDiscoveryRootState `json:"state"`
|
|
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
|
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
|
|
LastFailureCode string `json:"last_failure_code,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// DesktopDiscoveryMigration records upgrade-only state without turning the
|
|
// old implicit Home fallback into a new automatic scan.
|
|
type DesktopDiscoveryMigration struct {
|
|
HomeConfirmationRequired bool `json:"home_confirmation_required"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RepositoryBranchPolicy is a reusable branch workflow owned by one repository.
|
|
// It is configuration, not task history: task repositories copy these fields
|
|
// into their snapshot columns when a policy is selected.
|
|
type RepositoryBranchPolicy struct {
|
|
ID string `json:"id"`
|
|
RepositoryID string `json:"repository_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
BaseBranch string `json:"base_branch"`
|
|
BranchTemplate string `json:"branch_template"`
|
|
PullRequestTarget string `json:"pull_request_target"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RepositorySet is a named, reusable group of workspace repositories that fills
|
|
// the task-creation repository picker in one action.
|
|
//
|
|
// A set stores an optional base branch for each member. The task draft copies
|
|
// that preference when the set is applied; checkout state remains task-owned.
|
|
type RepositorySet struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
// Items is the ordered membership. Slice order is authoritative on write:
|
|
// positions are assigned from it, and reads return items sorted by position.
|
|
Items []RepositorySetItem `json:"repositories"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RepositorySetItem is one repository's membership in a set.
|
|
type RepositorySetItem struct {
|
|
ID string `json:"id"`
|
|
RepositorySetID string `json:"repository_set_id"`
|
|
RepositoryID string `json:"repository_id"`
|
|
Position int `json:"position"`
|
|
BaseBranch string `json:"base_branch"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RepositoryIDs returns the set's membership in position order.
|
|
func (s *RepositorySet) RepositoryIDs() []string {
|
|
ids := make([]string, 0, len(s.Items))
|
|
for _, item := range s.Items {
|
|
ids = append(ids, item.RepositoryID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// ProviderRepositoryIdentity is the durable provider lookup key. New plugin
|
|
// providers supply Scope + RepositoryID; legacy built-ins use Host/Owner/Name.
|
|
type ProviderRepositoryIdentity struct {
|
|
WorkspaceID string
|
|
Provider string
|
|
Scope string
|
|
RepositoryID string
|
|
Host string
|
|
Owner string
|
|
Name string
|
|
}
|
|
|
|
// RepositorySecretBinding maps an environment key to a secret reference. The
|
|
// value is never persisted or returned; a missing secret intentionally leaves
|
|
// this row dangling so launches can report a broken binding.
|
|
type RepositorySecretBinding struct {
|
|
RepositoryID string `json:"repository_id,omitempty"`
|
|
Key string `json:"key"`
|
|
SecretID string `json:"secret_id"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_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"
|
|
ExecutorTypeKubernetes ExecutorType = "k8s"
|
|
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, ExecutorTypeKubernetes, 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
|
|
case ExecutorTypeKubernetes:
|
|
return agentruntime.RuntimeKubernetes
|
|
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 || runtime == agentruntime.RuntimeKubernetes
|
|
}
|
|
|
|
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"`
|
|
OwnershipGeneration int64 `json:"ownership_generation"`
|
|
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"`
|
|
// MaterializationSessionID durably identifies the one session allowed to
|
|
// turn a creating environment into a physical workspace. It is empty once
|
|
// the environment is ready; sibling sessions must attach only.
|
|
MaterializationSessionID string `json:"-"`
|
|
|
|
// WorkspacePath points at the agent workspace root (the task root when
|
|
// TaskDirName is set, otherwise the single repo's worktree path).
|
|
// Physical worktree identity lives on Repos, never on the environment row.
|
|
WorkspacePath string `json:"workspace_path,omitempty"`
|
|
ContainerID string `json:"container_id,omitempty"`
|
|
// ContainerBootstrapNonceSecretID is an environment-scoped encrypted secret
|
|
// reference used only to establish a new agentctl control connection to an
|
|
// already-owned Docker container. It is deliberately not exposed in API
|
|
// responses and is distinct from session runtime/auth metadata.
|
|
ContainerBootstrapNonceSecretID string `json:"-"`
|
|
// ContainerControlAuthTokenSecretID is the environment-scoped encrypted
|
|
// agentctl control-token reference for a running Docker container. It is
|
|
// deliberately separate from a session's agent runtime/auth metadata.
|
|
ContainerControlAuthTokenSecretID string `json:"-"`
|
|
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.
|
|
// Each row is the single source of physical-worktree truth for that
|
|
// repository slot. Populated by repository getters.
|
|
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. The row is the single source of
|
|
// physical-worktree truth — identity, path, branch, status, and lifecycle
|
|
// timestamps.
|
|
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"`
|
|
WorktreeBranchOwner string `json:"-"`
|
|
WorktreeIntegrationRef string `json:"-"`
|
|
WorktreeRecoveryHeadSHA string `json:"-"`
|
|
WorktreeBranchCompactedAt *time.Time `json:"-"`
|
|
Position int `json:"position"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
MergedAt *time.Time `json:"merged_at,omitempty"`
|
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
|
}
|
|
|
|
// TaskEnvironmentRecoveryClaimRequest identifies the environment authority
|
|
// required while an automatic host-worktree recovery is in progress.
|
|
type TaskEnvironmentRecoveryClaimRequest struct {
|
|
TaskEnvironmentID string
|
|
OwnerTaskID string
|
|
OwnershipGeneration int64
|
|
SessionID string
|
|
OperationID string
|
|
ExecutorType string
|
|
}
|
|
|
|
// TaskEnvironmentRecoveryClaim is the durable authority held from recovery
|
|
// preflight through the external workspace-start boundary.
|
|
type TaskEnvironmentRecoveryClaim struct {
|
|
TaskEnvironmentID string `json:"task_environment_id"`
|
|
OwnerTaskID string `json:"owner_task_id"`
|
|
OwnershipGeneration int64 `json:"ownership_generation"`
|
|
SessionID string `json:"session_id"`
|
|
OperationID string `json:"operation_id"`
|
|
ExecutorType string `json:"executor_type"`
|
|
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,
|
|
"executor_type": te.ExecutorType,
|
|
"executor_id": te.ExecutorID,
|
|
"executor_profile_id": te.ExecutorProfileID,
|
|
"status": string(te.Status),
|
|
"workspace_path": te.WorkspacePath,
|
|
createdAtField: 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.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,
|
|
createdAtField: 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"
|
|
// WriteVersion changes on every committed title/content write. It is an
|
|
// internal optimistic-concurrency token and is not part of browser DTOs.
|
|
WriteVersion string `json:"-"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CommentsRevision int64 `json:"comments_revision"`
|
|
ImplementationStartedAt *time.Time `json:"implementation_started_at,omitempty"`
|
|
ImplementationStartedSessionID *string `json:"implementation_started_session_id,omitempty"`
|
|
ImplementationStartedBy *string `json:"implementation_started_by,omitempty"`
|
|
}
|
|
|
|
// TaskPlanComment is pending user feedback attached to the current task plan.
|
|
type TaskPlanComment struct {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"task_id"`
|
|
PlanID string `json:"plan_id"`
|
|
Body string `json:"body"`
|
|
SelectedText string `json:"selected_text"`
|
|
AnchorFrom int `json:"anchor_from"`
|
|
AnchorTo int `json:"anchor_to"`
|
|
Version int64 `json:"version"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// TaskPlanCommentSnapshot is the authoritative pending-comment collection for a plan.
|
|
type TaskPlanCommentSnapshot struct {
|
|
TaskID string `json:"task_id" db:"task_id"`
|
|
PlanID string `json:"plan_id" db:"plan_id"`
|
|
Revision int64 `json:"revision" db:"revision"`
|
|
Comments []*TaskPlanComment `json:"comments"`
|
|
}
|
|
|
|
// TaskPlanCommentRef identifies the exact pending-comment version a delivery includes.
|
|
type TaskPlanCommentRef struct {
|
|
ID string `json:"id"`
|
|
Version int64 `json:"version"`
|
|
}
|
|
|
|
// 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"`
|
|
// ContentBytes is populated by bounded metadata reads. Full revision reads
|
|
// leave it zero because callers can derive the size from Content.
|
|
ContentBytes int `json:"-"`
|
|
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"`
|
|
// WorkflowStepID/Name/Color snapshot the task's workflow step at write
|
|
// time, same display-snapshot pattern as AuthorName. Empty for revisions
|
|
// written before this stamping existed, and preserved as-is (not
|
|
// re-stamped) when a later write coalesces into this row.
|
|
WorkflowStepID string `json:"workflow_step_id,omitempty"`
|
|
WorkflowStepName string `json:"workflow_step_name,omitempty"`
|
|
WorkflowStepColor string `json:"workflow_step_color,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"`
|
|
// EntryID is the step-transition ledger row identifier of the step entry
|
|
// that requested this run, when the run was requested by the
|
|
// run_code_review step-entry action. Empty for manual/MCP-triggered runs.
|
|
// Durable dedup key for AC-OFFICE-STEP-ENTRY-001.10: a redelivery of the
|
|
// same entry must rejoin this run rather than start a second one.
|
|
EntryID string `json:"entry_id,omitempty"`
|
|
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/agents/requirements/native-code-review.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"`
|
|
}
|
|
|
|
// TaskMessageAttachment is the durable registry row for a prompt attachment.
|
|
// Message and queue metadata carry only the public descriptor; the bytes stay
|
|
// in private backend storage addressed by StorageKey.
|
|
type TaskMessageAttachment struct {
|
|
ID string `json:"id" db:"id"`
|
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
|
|
TaskID string `json:"task_id,omitempty" db:"task_id"`
|
|
SessionID string `json:"session_id,omitempty" db:"session_id"`
|
|
MessageID string `json:"message_id,omitempty" db:"message_id"`
|
|
QueueID string `json:"queue_id,omitempty" db:"queue_id"`
|
|
Name string `json:"name" db:"name"`
|
|
MimeType string `json:"mime_type" db:"mime_type"`
|
|
Kind string `json:"kind" db:"kind"`
|
|
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
|
|
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
|
StorageKey string `json:"-" db:"storage_key"`
|
|
State string `json:"state" db:"state"`
|
|
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
}
|
|
|
|
const (
|
|
AttachmentStateStaged = "staged"
|
|
AttachmentStateClaimed = "claimed"
|
|
AttachmentStateExpired = "expired"
|
|
AttachmentStateDeleted = "deleted"
|
|
)
|
|
|
|
// 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{
|
|
CheckoutOptions: PublicRepositoryCheckoutOptions(repo.Metadata),
|
|
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: PublicTaskMetadata(t.Metadata),
|
|
Interrupted: t.Metadata[MetaKeyInterruptedAt] != nil,
|
|
AutoStartFailed: t.Metadata[MetaKeyAutoStartFailed] != nil,
|
|
WorkspaceOrphaned: WorkspaceOrphaned(t.Metadata),
|
|
IsEphemeral: t.IsEphemeral,
|
|
ParentID: t.ParentID,
|
|
Autopilot: t.Autopilot,
|
|
}
|
|
if t.Identifier != "" {
|
|
result.Identifier = t.Identifier
|
|
}
|
|
return result
|
|
}
|