Files
kandev/apps/backend/internal/task/dto/dto.go
T
João Salavisa 6ceda6ae4e fix(local-executor): preserve commits in changes panel after refresh (#834)
* fix(local-executor): preserve commits in changes panel after refresh

For a local executor on a feature branch, the create-task dialog sent
the chip's branch as base_branch with empty checkout_branch. agentctl
then recomputed merge-base(HEAD, origin/<branch>) on every changes-panel
refresh; for a never-pushed branch that collapsed to HEAD and the panel
went empty. Repository creation also pinned default_branch to .git/HEAD,
permanently anchoring the repo's integration ref to whatever feature
branch the user happened to be on at first task-create time.

The frontend now splits the chip into base_branch=default_branch +
checkout_branch=working_branch (skipping the split when fresh-branch is
on, where the chip semantically means "fork from"). The backend probes
origin/HEAD then origin/main → main → master (new common/gitref
package) to pick a repo's integration branch, and resolveRepoInput no
longer falls back to BaseBranch when default_branch is missing.

Covered by 9 unit tests on the helper, 6 Go tests on the probe order,
and 7 e2e tests across plain creation, the fork toggle, and the
validate-path probe.

* fix: address PR review feedback

- gitref: preserve slashes in HEAD-fallback branch names (TrimPrefix
  refs/heads/ instead of Split + last component); branches like
  "feature/my-feature" no longer collapse to "my-feature" and poison
  the stored default_branch.
- gitref: drop the broken packed-refs symref scan in readOriginHEAD;
  the loose refs/remotes/origin/HEAD check + pickFirstExistingBranch
  candidates already cover every realistic clone.
- service.resolveRepoInput: validate repoInput.LocalPath via
  resolveAllowedLocalPath() before probing — closes 8 CodeQL
  path-injection alerts.
- buildRepositoriesPayload: fall through to the legacy non-split shape
  when default_branch is unknown for the row (legacy repos with unset
  default_branch). Synthesizing base_branch=rowBranch was reproducing
  the very bug this PR fixes.

* fix(gitref): explicit traversal guard at I/O boundary for CodeQL

Move the path validation from service.resolveAllowedLocalPath (which
CodeQL's taint analysis doesn't recognize as a sanitizer) into a
guardRepoPath check at gitref.DefaultBranch's entry. The new check
rejects relative paths and any '..' segments before any os.Stat /
os.ReadFile call sees the value, which CodeQL recognizes as a
go/path-injection sanitizer.

The upstream allowlist check stays in place — this is a belt-and-
suspenders second barrier at the actual I/O boundary, and it also
makes the gitref package safe for any future callers (migrations,
tests) without requiring them to thread a Service in.

* fix: address review feedback on hardening + e2e race

- gitref.guardRepoPath: check the RAW input for '..' segments before
  filepath.Clean. Clean resolves '..' away from absolute paths
  ("/allowed/../etc" -> "/etc"), so iterating the cleaned path was a
  silent no-op for the very inputs we wanted to reject. Split on both
  '/' and the OS separator. Updated the test with three traversal
  cases.
- gitref_test.go: switch from strings.EqualFold to strict equality —
  git branch names are case-sensitive, "Main" should not pass for
  "main".
- e2e: replace waitForRequest with waitForResponse in
  local-executor-branch-split.spec.ts. waitForRequest resolves on send
  (before the server has persisted the task), so the followup
  apiClient.getTask call could race the write on slow CI. The two
  tests that need the request body keep access via
  (await waitForResponse).request().

* fix(ws): forward CheckoutBranch in wsUpdateTask

The PR fix added CheckoutBranch forwarding to wsCreateTask but missed
the same conversion loop in wsUpdateTask. Any task-update payload with
a repositories array would silently zero out checkout_branch and
re-introduce the merge-base collapse on refresh — same root cause this
PR fixes, in the update path.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:05:01 +01:00

773 lines
30 KiB
Go

package dto
import (
"time"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type WorkflowDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
SortOrder int `json:"sort_order"`
Hidden bool `json:"hidden,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WorkspaceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
OwnerID string `json:"owner_id"`
DefaultExecutorID *string `json:"default_executor_id,omitempty"`
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RepositoryDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
SourceType string `json:"source_type"`
LocalPath string `json:"local_path"`
Provider string `json:"provider"`
ProviderRepoID string `json:"provider_repo_id"`
ProviderOwner string `json:"provider_owner"`
ProviderName string `json:"provider_name"`
DefaultBranch string `json:"default_branch"`
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
PullBeforeWorktree bool `json:"pull_before_worktree"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Scripts []RepositoryScriptDTO `json:"scripts,omitempty"`
}
type RepositoryScriptDTO 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"`
}
type ExecutorDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type models.ExecutorType `json:"type"`
Status models.ExecutorStatus `json:"status"`
IsSystem bool `json:"is_system"`
Resumable bool `json:"resumable"`
Config map[string]string `json:"config,omitempty"`
Profiles []ExecutorProfileDTO `json:"profiles,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ExecutorProfileDTO struct {
ID string `json:"id"`
ExecutorID string `json:"executor_id"`
ExecutorType string `json:"executor_type,omitempty"`
ExecutorName string `json:"executor_name,omitempty"`
Name string `json:"name"`
McpPolicy string `json:"mcp_policy,omitempty"`
Config map[string]string `json:"config,omitempty"`
PrepareScript string `json:"prepare_script"`
CleanupScript string `json:"cleanup_script"`
EnvVars []models.ProfileEnvVar `json:"env_vars,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListExecutorProfilesResponse struct {
Profiles []ExecutorProfileDTO `json:"profiles"`
Total int `json:"total"`
}
type EnvironmentDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Kind models.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"`
}
type TaskDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
WorkflowID string `json:"workflow_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
Repositories []TaskRepositoryDTO `json:"repositories,omitempty"`
Position int `json:"position"`
PrimarySessionID *string `json:"primary_session_id,omitempty"`
SessionCount *int `json:"session_count,omitempty"`
ReviewStatus *string `json:"review_status,omitempty"`
PrimaryExecutorID *string `json:"primary_executor_id,omitempty"`
PrimaryExecutorType *string `json:"primary_executor_type,omitempty"`
PrimaryExecutorName *string `json:"primary_executor_name,omitempty"`
PrimaryAgentName *string `json:"primary_agent_name,omitempty"`
PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"`
PrimarySessionState *string `json:"primary_session_state,omitempty"`
IsRemoteExecutor bool `json:"is_remote_executor,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type TaskRepositoryDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
CheckoutBranch string `json:"checkout_branch,omitempty"`
Position int `json:"position"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TaskSessionDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ExecutorProfileID string `json:"executor_profile_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
RepositoryID string `json:"repository_id,omitempty"`
BaseBranch string `json:"base_branch,omitempty"`
BaseCommitSHA string `json:"base_commit_sha,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
State models.TaskSessionState `json:"state"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
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"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
// Workflow fields
IsPrimary bool `json:"is_primary"`
IsPassthrough bool `json:"is_passthrough"`
ReviewStatus *string `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
}
// TaskSessionSummaryDTO is a lightweight version of TaskSessionDTO without snapshot fields.
// Used for list endpoints where snapshots are not needed, reducing response size by ~40-60%.
type TaskSessionSummaryDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ExecutorProfileID string `json:"executor_profile_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
RepositoryID string `json:"repository_id,omitempty"`
BaseBranch string `json:"base_branch,omitempty"`
BaseCommitSHA string `json:"base_commit_sha,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
State models.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"`
IsPrimary bool `json:"is_primary"`
IsPassthrough bool `json:"is_passthrough"`
ReviewStatus *string `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
}
// ListTaskSessionSummariesResponse is the list response using summary DTOs.
type ListTaskSessionSummariesResponse struct {
Sessions []TaskSessionSummaryDTO `json:"sessions"`
Total int `json:"total"`
}
type GetTaskSessionResponse struct {
Session TaskSessionDTO `json:"session"`
}
type ListTaskSessionsResponse struct {
Sessions []TaskSessionDTO `json:"sessions"`
Total int `json:"total"`
}
type WorkflowSnapshotDTO struct {
Workflow WorkflowDTO `json:"workflow"`
Steps []WorkflowStepDTO `json:"steps"`
Tasks []TaskDTO `json:"tasks"`
}
type ListMessagesResponse struct {
Messages []*v1.Message `json:"messages"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
Cursor string `json:"cursor"`
}
// MessageSearchHit is a lightweight match returned by a session message search.
type MessageSearchHit struct {
ID string `json:"id"`
TurnID string `json:"turn_id,omitempty"`
AuthorType string `json:"author_type"`
Type string `json:"type"`
Snippet string `json:"snippet"`
CreatedAt time.Time `json:"created_at"`
}
// SearchMessagesResponse contains hits from a session message search.
type SearchMessagesResponse struct {
Hits []MessageSearchHit `json:"hits"`
Total int `json:"total"`
}
type TurnDTO struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
StartedAt string `json:"started_at"`
CompletedAt *string `json:"completed_at,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ListTurnsResponse struct {
Turns []TurnDTO `json:"turns"`
Total int `json:"total"`
}
type ListWorkflowsResponse struct {
Workflows []WorkflowDTO `json:"workflows"`
Total int `json:"total"`
}
type ListWorkspacesResponse struct {
Workspaces []WorkspaceDTO `json:"workspaces"`
Total int `json:"total"`
}
type ListRepositoriesResponse struct {
Repositories []RepositoryDTO `json:"repositories"`
Total int `json:"total"`
}
type ListRepositoryScriptsResponse struct {
Scripts []RepositoryScriptDTO `json:"scripts"`
Total int `json:"total"`
}
type ListExecutorsResponse struct {
Executors []ExecutorDTO `json:"executors"`
Total int `json:"total"`
}
type ListEnvironmentsResponse struct {
Environments []EnvironmentDTO `json:"environments"`
Total int `json:"total"`
}
type BranchDTO struct {
Name string `json:"name"`
Type string `json:"type"` // "local" or "remote"
Remote string `json:"remote"` // remote name (e.g., "origin") for remote branches
}
type RepositoryBranchesResponse struct {
Branches []BranchDTO `json:"branches"`
Total int `json:"total"`
CurrentBranch string `json:"current_branch,omitempty"`
// FetchedAt is the timestamp of the most recent `git fetch` for this
// repository (RFC3339). Empty when no refresh has been requested in the
// process lifetime.
FetchedAt string `json:"fetched_at,omitempty"`
// FetchError is the human-readable error from the last fetch attempt for
// this request, if one was attempted and failed. Empty otherwise.
FetchError string `json:"fetch_error,omitempty"`
}
// LocalRepositoryStatusResponse reports current branch + dirty paths for a
// local repository on disk (no session required). Used by the task-create
// dialog to preflight the fresh-branch flow on local executors.
type LocalRepositoryStatusResponse struct {
CurrentBranch string `json:"current_branch"`
DirtyFiles []string `json:"dirty_files"`
}
type LocalRepositoryDTO struct {
Path string `json:"path"`
Name string `json:"name"`
DefaultBranch string `json:"default_branch,omitempty"`
}
type RepositoryDiscoveryResponse struct {
Roots []string `json:"roots"`
Repositories []LocalRepositoryDTO `json:"repositories"`
Total int `json:"total"`
}
type RepositoryPathValidationResponse struct {
Path string `json:"path"`
Exists bool `json:"exists"`
IsGitRepo bool `json:"is_git"`
Allowed bool `json:"allowed"`
DefaultBranch string `json:"default_branch,omitempty"`
Message string `json:"message,omitempty"`
}
type ListTasksResponse struct {
Tasks []TaskDTO `json:"tasks"`
Total int `json:"total"`
}
type SuccessResponse struct {
Success bool `json:"success"`
}
func FromWorkflow(workflow *models.Workflow) WorkflowDTO {
var description *string
if workflow.Description != "" {
description = &workflow.Description
}
return WorkflowDTO{
ID: workflow.ID,
WorkspaceID: workflow.WorkspaceID,
Name: workflow.Name,
Description: description,
AgentProfileID: workflow.AgentProfileID,
SortOrder: workflow.SortOrder,
Hidden: workflow.Hidden,
CreatedAt: workflow.CreatedAt,
UpdatedAt: workflow.UpdatedAt,
}
}
func FromWorkspace(workspace *models.Workspace) WorkspaceDTO {
var description *string
if workspace.Description != "" {
description = &workspace.Description
}
return WorkspaceDTO{
ID: workspace.ID,
Name: workspace.Name,
Description: description,
OwnerID: workspace.OwnerID,
DefaultExecutorID: workspace.DefaultExecutorID,
DefaultEnvironmentID: workspace.DefaultEnvironmentID,
DefaultAgentProfileID: workspace.DefaultAgentProfileID,
DefaultConfigAgentProfileID: workspace.DefaultConfigAgentProfileID,
CreatedAt: workspace.CreatedAt,
UpdatedAt: workspace.UpdatedAt,
}
}
func FromRepository(repository *models.Repository) RepositoryDTO {
return RepositoryDTO{
ID: repository.ID,
WorkspaceID: repository.WorkspaceID,
Name: repository.Name,
SourceType: repository.SourceType,
LocalPath: repository.LocalPath,
Provider: repository.Provider,
ProviderRepoID: repository.ProviderRepoID,
ProviderOwner: repository.ProviderOwner,
ProviderName: repository.ProviderName,
DefaultBranch: repository.DefaultBranch,
WorktreeBranchPrefix: repository.WorktreeBranchPrefix,
PullBeforeWorktree: repository.PullBeforeWorktree,
SetupScript: repository.SetupScript,
CleanupScript: repository.CleanupScript,
DevScript: repository.DevScript,
CreatedAt: repository.CreatedAt,
UpdatedAt: repository.UpdatedAt,
}
}
func FromRepositoryScript(script *models.RepositoryScript) RepositoryScriptDTO {
return RepositoryScriptDTO{
ID: script.ID,
RepositoryID: script.RepositoryID,
Name: script.Name,
Command: script.Command,
Position: script.Position,
CreatedAt: script.CreatedAt,
UpdatedAt: script.UpdatedAt,
}
}
func FromExecutor(executor *models.Executor) ExecutorDTO {
return ExecutorDTO{
ID: executor.ID,
Name: executor.Name,
Type: executor.Type,
Status: executor.Status,
IsSystem: executor.IsSystem,
Resumable: executor.Resumable,
Config: executor.Config,
CreatedAt: executor.CreatedAt,
UpdatedAt: executor.UpdatedAt,
}
}
func FromExecutorProfile(profile *models.ExecutorProfile) ExecutorProfileDTO {
return ExecutorProfileDTO{
ID: profile.ID,
ExecutorID: profile.ExecutorID,
Name: profile.Name,
McpPolicy: profile.McpPolicy,
Config: profile.Config,
PrepareScript: profile.PrepareScript,
CleanupScript: profile.CleanupScript,
EnvVars: profile.EnvVars,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}
}
// FromExecutorProfileWithExecutor converts an ExecutorProfile model to a DTO
// with executor type and name populated.
func FromExecutorProfileWithExecutor(profile *models.ExecutorProfile, executor *models.Executor) ExecutorProfileDTO {
d := FromExecutorProfile(profile)
if executor != nil {
d.ExecutorType = string(executor.Type)
d.ExecutorName = executor.Name
}
return d
}
func FromEnvironment(environment *models.Environment) EnvironmentDTO {
return EnvironmentDTO{
ID: environment.ID,
Name: environment.Name,
Kind: environment.Kind,
IsSystem: environment.IsSystem,
WorktreeRoot: environment.WorktreeRoot,
ImageTag: environment.ImageTag,
Dockerfile: environment.Dockerfile,
BuildConfig: environment.BuildConfig,
CreatedAt: environment.CreatedAt,
UpdatedAt: environment.UpdatedAt,
}
}
func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO {
return LocalRepositoryDTO{
Path: repo.Path,
Name: repo.Name,
DefaultBranch: repo.DefaultBranch,
}
}
func FromTask(task *models.Task) TaskDTO {
return FromTaskWithPrimarySession(task, nil)
}
// FromTaskWithPrimarySession converts a task model to a TaskDTO, including the primary session ID.
func FromTaskWithPrimarySession(task *models.Task, primarySessionID *string) TaskDTO {
return FromTaskWithSessionInfo(task, primarySessionID, nil, nil, nil, nil, nil, nil, nil, nil)
}
// FromTaskWithSessionInfo converts a task model to a TaskDTO, including session information.
func FromTaskWithSessionInfo(
task *models.Task,
primarySessionID *string,
sessionCount *int,
reviewStatus *string,
primaryExecutorID *string,
primaryExecutorType *string,
primaryExecutorName *string,
primaryAgentName *string,
primaryWorkingDirectory *string,
primarySessionState *string,
) TaskDTO {
// Convert repositories
var repositories []TaskRepositoryDTO
for _, repo := range task.Repositories {
repositories = append(repositories, TaskRepositoryDTO{
ID: repo.ID,
TaskID: repo.TaskID,
RepositoryID: repo.RepositoryID,
BaseBranch: repo.BaseBranch,
CheckoutBranch: repo.CheckoutBranch,
Position: repo.Position,
Metadata: repo.Metadata,
CreatedAt: repo.CreatedAt,
UpdatedAt: repo.UpdatedAt,
})
}
return TaskDTO{
ID: task.ID,
WorkspaceID: task.WorkspaceID,
WorkflowID: task.WorkflowID,
WorkflowStepID: task.WorkflowStepID,
Title: task.Title,
Description: task.Description,
State: task.State,
Priority: task.Priority,
Repositories: repositories,
Position: task.Position,
PrimarySessionID: primarySessionID,
SessionCount: sessionCount,
ReviewStatus: reviewStatus,
PrimaryExecutorID: primaryExecutorID,
PrimaryExecutorType: primaryExecutorType,
PrimaryExecutorName: primaryExecutorName,
PrimaryAgentName: primaryAgentName,
PrimaryWorkingDirectory: primaryWorkingDirectory,
PrimarySessionState: primarySessionState,
IsRemoteExecutor: primaryExecutorType != nil && models.IsRemoteExecutorType(models.ExecutorType(*primaryExecutorType)),
ParentID: task.ParentID,
ArchivedAt: task.ArchivedAt,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
Metadata: task.Metadata,
}
}
// FromTaskSessionSummary converts a session model to a summary DTO (no snapshot fields).
func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO {
result := TaskSessionSummaryDTO{
ID: session.ID,
TaskID: session.TaskID,
AgentExecutionID: session.AgentExecutionID,
ContainerID: session.ContainerID,
AgentProfileID: session.AgentProfileID,
ExecutorID: session.ExecutorID,
ExecutorProfileID: session.ExecutorProfileID,
EnvironmentID: session.EnvironmentID,
RepositoryID: session.RepositoryID,
BaseBranch: session.BaseBranch,
BaseCommitSHA: session.BaseCommitSHA,
State: session.State,
ErrorMessage: session.ErrorMessage,
Metadata: session.Metadata,
StartedAt: session.StartedAt,
CompletedAt: session.CompletedAt,
UpdatedAt: session.UpdatedAt,
IsPrimary: session.IsPrimary,
IsPassthrough: session.IsPassthrough,
ReviewStatus: session.ReviewStatus,
TaskEnvironmentID: session.TaskEnvironmentID,
}
if len(session.Worktrees) > 0 {
result.WorktreeID = session.Worktrees[0].WorktreeID
result.WorktreePath = session.Worktrees[0].WorktreePath
result.WorktreeBranch = session.Worktrees[0].WorktreeBranch
}
return result
}
func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
result := TaskSessionDTO{
ID: session.ID,
TaskID: session.TaskID,
AgentExecutionID: session.AgentExecutionID,
ContainerID: session.ContainerID,
AgentProfileID: session.AgentProfileID,
ExecutorID: session.ExecutorID,
ExecutorProfileID: session.ExecutorProfileID,
EnvironmentID: session.EnvironmentID,
RepositoryID: session.RepositoryID,
BaseBranch: session.BaseBranch,
BaseCommitSHA: session.BaseCommitSHA,
State: session.State,
ErrorMessage: session.ErrorMessage,
Metadata: session.Metadata,
AgentProfileSnapshot: session.AgentProfileSnapshot,
ExecutorSnapshot: session.ExecutorSnapshot,
EnvironmentSnapshot: session.EnvironmentSnapshot,
RepositorySnapshot: session.RepositorySnapshot,
StartedAt: session.StartedAt,
CompletedAt: session.CompletedAt,
UpdatedAt: session.UpdatedAt,
// Workflow fields
IsPrimary: session.IsPrimary,
IsPassthrough: session.IsPassthrough,
ReviewStatus: session.ReviewStatus,
TaskEnvironmentID: session.TaskEnvironmentID,
}
if len(session.Worktrees) > 0 {
result.WorktreeID = session.Worktrees[0].WorktreeID
result.WorktreePath = session.Worktrees[0].WorktreePath
result.WorktreeBranch = session.Worktrees[0].WorktreeBranch
}
return result
}
// WorkflowStepDTO represents a workflow step for API responses
type WorkflowStepDTO struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
Name string `json:"name"`
Position int `json:"position"`
Color string `json:"color"`
Prompt string `json:"prompt,omitempty"`
Events *StepEventsDTO `json:"events,omitempty"`
AllowManualMove bool `json:"allow_manual_move"`
IsStartStep bool `json:"is_start_step"`
ShowInCommandPanel bool `json:"show_in_command_panel"`
AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StepEventsDTO represents step events for API responses
type StepEventsDTO struct {
OnEnter []StepActionDTO `json:"on_enter,omitempty"`
OnTurnComplete []StepActionDTO `json:"on_turn_complete,omitempty"`
}
// StepActionDTO represents a step action for API responses
type StepActionDTO struct {
Type string `json:"type"`
Config map[string]interface{} `json:"config,omitempty"`
}
// MoveTaskResponse includes the task and the target workflow step info
type MoveTaskResponse struct {
Task TaskDTO `json:"task"`
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
}
// Session Workflow Review DTOs
// ApproveSessionRequest is the request to approve a session's current step
type ApproveSessionRequest struct {
SessionID string `json:"-"` // From URL path
}
// ApproveSessionResponse is the response after approving a session
type ApproveSessionResponse struct {
Success bool `json:"success"`
Session TaskSessionDTO `json:"session"`
WorkflowStep WorkflowStepDTO `json:"workflow_step,omitempty"` // New step after approval
}
// TaskPlanDTO represents a task plan for API responses
type TaskPlanDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskPlanFromModel converts a TaskPlan model to a TaskPlanDTO.
func TaskPlanFromModel(plan *models.TaskPlan) *TaskPlanDTO {
if plan == nil {
return nil
}
return &TaskPlanDTO{
ID: plan.ID,
TaskID: plan.TaskID,
Title: plan.Title,
Content: plan.Content,
CreatedBy: plan.CreatedBy,
CreatedAt: plan.CreatedAt,
UpdatedAt: plan.UpdatedAt,
}
}
// TaskPlanRevisionDTO represents a plan revision for API responses.
// Content is optional so list responses can omit it (fetched on demand).
type TaskPlanRevisionDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RevisionNumber int `json:"revision_number"`
Title string `json:"title"`
Content string `json:"content,omitempty"`
AuthorKind string `json:"author_kind"`
AuthorName string `json:"author_name"`
RevertOfRevisionID *string `json:"revert_of_revision_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskPlanRevisionFromModel converts a TaskPlanRevision model with content.
func TaskPlanRevisionFromModel(rev *models.TaskPlanRevision) *TaskPlanRevisionDTO {
if rev == nil {
return nil
}
return &TaskPlanRevisionDTO{
ID: rev.ID,
TaskID: rev.TaskID,
RevisionNumber: rev.RevisionNumber,
Title: rev.Title,
Content: rev.Content,
AuthorKind: rev.AuthorKind,
AuthorName: rev.AuthorName,
RevertOfRevisionID: rev.RevertOfRevisionID,
CreatedAt: rev.CreatedAt,
UpdatedAt: rev.UpdatedAt,
}
}
// TaskPlanRevisionMetaFromModel converts without content (for list payloads/WS broadcasts).
func TaskPlanRevisionMetaFromModel(rev *models.TaskPlanRevision) *TaskPlanRevisionDTO {
meta := TaskPlanRevisionFromModel(rev)
if meta != nil {
meta.Content = ""
}
return meta
}
// FromTurn converts a Turn model to a TurnDTO.
func FromTurn(turn *models.Turn) TurnDTO {
var completedAt *string
if turn.CompletedAt != nil {
formatted := turn.CompletedAt.UTC().Format(time.RFC3339)
completedAt = &formatted
}
return TurnDTO{
ID: turn.ID,
SessionID: turn.TaskSessionID,
TaskID: turn.TaskID,
StartedAt: turn.StartedAt.UTC().Format(time.RFC3339),
CompletedAt: completedAt,
Metadata: turn.Metadata,
CreatedAt: turn.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: turn.UpdatedAt.UTC().Format(time.RFC3339),
}
}