Files
kandev/apps/backend/internal/task/service/plan_service.go
T
2026-08-21 19:28:21 +01:00

571 lines
17 KiB
Go

package service
import (
"context"
"errors"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
)
var (
ErrTaskPlanNotFound = errors.New("task plan not found")
ErrTaskIDRequired = errors.New("task_id is required")
ErrContentRequired = errors.New("content is required")
ErrSessionIDRequired = errors.New("session_id is required")
ErrRevisionNotFound = errors.New("task plan revision not found")
ErrRevisionIDRequired = errors.New("target_revision_id is required")
ErrRevisionTaskMismatch = errors.New("revision does not belong to given task")
ErrSessionTaskMismatch = errors.New("session does not belong to given task")
)
const (
createdByAgent = "agent"
createdByUser = "user"
defaultCoalesceWindow = 5 * time.Minute
defaultAgentAuthorFallback = "Agent"
defaultUserAuthorFallback = "User"
)
// planRepo is the repository surface this service depends on. It combines the
// plan-revision storage with a tiny slice of session lookups used to resolve
// the active session's agent profile name when the MCP path doesn't provide
// an explicit author_name.
type planRepo interface {
repository.PlanRepository
GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
}
// PlanService provides task plan business logic.
type PlanService struct {
repo planRepo
eventBus bus.EventBus
logger *logger.Logger
coalesceWindow time.Duration
// authorizeTask gates plan access by the task's workspace ownership
// (opt-in auth). Nil = unscoped (internal callers / auth disabled).
authorizeTask func(ctx context.Context, taskID string) error
}
// NewPlanService creates a new task plan service. The concrete repository
// passed by callers must implement both PlanRepository and the session-lookup
// methods on planRepo (the SQLite repository does both).
func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger, configuredWindow ...time.Duration) *PlanService {
coalesceWindow := defaultCoalesceWindow
if len(configuredWindow) > 0 {
coalesceWindow = resolveCoalesceWindow(configuredWindow[0])
}
return &PlanService{
repo: repo,
eventBus: eventBus,
logger: log.WithFields(zap.String("component", "plan-service")),
coalesceWindow: coalesceWindow,
}
}
// SetTaskAuthorizer wires the per-user task-access check (opt-in auth). The
// authorizer must return nil for contexts without a request identity.
func (s *PlanService) SetTaskAuthorizer(fn func(ctx context.Context, taskID string) error) {
s.authorizeTask = fn
}
func (s *PlanService) authorize(ctx context.Context, taskID string) error {
if s.authorizeTask == nil {
return nil
}
return s.authorizeTask(ctx, taskID)
}
func resolveCoalesceWindow(window time.Duration) time.Duration {
if window < 0 {
return defaultCoalesceWindow
}
return window
}
// CreatePlanRequest contains parameters for creating/updating a task plan.
// AuthorKind and AuthorName are optional; when absent they are derived from CreatedBy.
type CreatePlanRequest struct {
TaskID string
Title string
Content string
CreatedBy string // "agent" | "user"
AuthorKind string // optional explicit override
AuthorName string // optional; display snapshot
}
// CreatePlan upserts a plan and appends or coalesces a revision.
func (s *PlanService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*models.TaskPlan, error) {
if err := s.authorize(ctx, req.TaskID); err != nil {
return nil, err
}
return s.upsertPlan(ctx, req)
}
// UpdatePlanRequest mirrors CreatePlanRequest; kept as a distinct type for API clarity.
type UpdatePlanRequest struct {
TaskID string
Title string
Content string
CreatedBy string
AuthorKind string
AuthorName string
}
// UpdatePlan updates an existing plan (errors if missing).
func (s *PlanService) UpdatePlan(ctx context.Context, req UpdatePlanRequest) (*models.TaskPlan, error) {
if req.TaskID == "" {
return nil, ErrTaskIDRequired
}
if err := s.authorize(ctx, req.TaskID); err != nil {
return nil, err
}
existing, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, ErrTaskPlanNotFound
}
title := req.Title
if title == "" {
title = existing.Title
}
createdBy := req.CreatedBy
if createdBy == "" {
createdBy = existing.CreatedBy
}
return s.upsertPlan(ctx, CreatePlanRequest{
TaskID: req.TaskID,
Title: title,
Content: req.Content,
CreatedBy: createdBy,
AuthorKind: req.AuthorKind,
AuthorName: req.AuthorName,
})
}
// upsertPlan is the shared write path. It upserts the task_plans HEAD row and either
// coalesces into the latest revision (same author within window) or appends a new revision
// — both steps run in one write transaction via WritePlanRevision so HEAD and history
// cannot diverge under concurrent writers or partial failures.
func (s *PlanService) upsertPlan(ctx context.Context, req CreatePlanRequest) (*models.TaskPlan, error) {
if req.TaskID == "" {
return nil, ErrTaskIDRequired
}
title := req.Title
if title == "" {
title = "Plan"
}
// Resolve a missing AuthorName for agent writes from the active session's
// profile snapshot before falling back to the literal "Agent". The MCP path
// (handleCreateTaskPlan / handleUpdateTaskPlan) doesn't carry the agent's
// display name in the request, so without this lookup every agent revision
// would render as "Agent" in the history UI.
if req.AuthorName == "" {
kindHint := req.AuthorKind
if kindHint == "" {
kindHint = req.CreatedBy
}
if kindHint == createdByAgent {
req.AuthorName = s.resolveAgentDisplayName(ctx, req.TaskID)
}
}
authorKind, authorName, createdBy := resolveAuthor(req)
existing, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
s.logger.Error("get existing plan", zap.String("task_id", req.TaskID), zap.Error(err))
return nil, err
}
eventType := events.TaskPlanCreated
if existing != nil {
eventType = events.TaskPlanUpdated
}
plan := &models.TaskPlan{
TaskID: req.TaskID,
Title: title,
Content: req.Content,
CreatedBy: createdBy,
}
if existing != nil {
plan.ID = existing.ID
plan.CreatedAt = existing.CreatedAt
}
latest, err := s.repo.GetLatestTaskPlanRevision(ctx, req.TaskID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
coalesce := s.canCoalesce(latest, authorKind, authorName, now)
rev := &models.TaskPlanRevision{
TaskID: req.TaskID,
Title: title,
Content: req.Content,
AuthorKind: authorKind,
AuthorName: authorName,
}
var coalesceID *string
if coalesce {
coalesceID = &latest.ID
// Preserve the original revision's author + number on merge.
rev.RevisionNumber = latest.RevisionNumber
rev.AuthorKind = latest.AuthorKind
rev.AuthorName = latest.AuthorName
rev.CreatedAt = latest.CreatedAt
}
if err := s.repo.WritePlanRevision(ctx, plan, rev, coalesceID); err != nil {
s.logger.Error("write plan revision", zap.String("task_id", req.TaskID), zap.Error(err))
return nil, err
}
saved, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
return nil, err
}
if saved == nil {
return nil, ErrTaskPlanNotFound
}
s.publishPlanEvent(ctx, eventType, saved)
s.publishRevisionEvent(ctx, rev, coalesce)
return saved, nil
}
// resolveAgentDisplayName returns the agent profile's display name for the
// task's most recent session, or "" if no usable session/snapshot exists.
// Tries the active session first (running/starting/waiting) and falls back to
// the most recent session by started_at so plans written between turns still
// get the right author name.
func (s *PlanService) resolveAgentDisplayName(ctx context.Context, taskID string) string {
session, err := s.repo.GetActiveTaskSessionByTaskID(ctx, taskID)
if err != nil || session == nil {
session, err = s.repo.GetTaskSessionByTaskID(ctx, taskID)
if err != nil || session == nil {
return ""
}
}
return agentDisplayNameFromSnapshot(session.AgentProfileSnapshot)
}
// agentDisplayNameFromSnapshot picks the best available display name from a
// session's agent_profile_snapshot. The orchestrator's canonical key is
// "name" (the profile's display name, e.g. "Claude Sonnet 4.5"); we try it
// first so a snapshot that carries both "name" and a stale older "label"
// doesn't render the stale value. Falls back through "label" (older paths)
// and "agent_display_name" (some DTO mappings) before giving up.
func agentDisplayNameFromSnapshot(snapshot map[string]interface{}) string {
if snapshot == nil {
return ""
}
for _, key := range []string{"name", "label", "agent_display_name"} {
if v, ok := snapshot[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
func (s *PlanService) canCoalesce(latest *models.TaskPlanRevision, authorKind, authorName string, now time.Time) bool {
if latest == nil {
return false
}
if latest.RevertOfRevisionID != nil {
return false // revert markers are permanent
}
if latest.AuthorKind != authorKind || latest.AuthorName != authorName {
return false
}
if s.coalesceWindow <= 0 {
return false
}
return now.Sub(latest.UpdatedAt) < s.coalesceWindow
}
// resolveAuthor derives the authoritative (kind, name, legacyCreatedBy) tuple
// from a write request. Callers may pass explicit AuthorKind/AuthorName; when
// absent we fall back to CreatedBy and a literal display name.
func resolveAuthor(req CreatePlanRequest) (kind, name, createdBy string) {
createdBy = req.CreatedBy
kind = req.AuthorKind
if kind == "" {
kind = createdBy
}
if kind != createdByAgent && kind != createdByUser {
kind = createdByAgent
}
if createdBy == "" {
createdBy = kind
}
name = req.AuthorName
if name == "" {
if kind == createdByAgent {
name = defaultAgentAuthorFallback
} else {
name = defaultUserAuthorFallback
}
}
return kind, name, createdBy
}
// GetPlan retrieves a task plan by task ID. Returns nil, nil if missing.
func (s *PlanService) GetPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) {
if taskID == "" {
return nil, ErrTaskIDRequired
}
if err := s.authorize(ctx, taskID); err != nil {
return nil, err
}
return s.repo.GetTaskPlan(ctx, taskID)
}
type MarkImplementationStartedRequest struct {
TaskID string
SessionID string
Actor string
}
func (s *PlanService) MarkImplementationStarted(ctx context.Context, req MarkImplementationStartedRequest) (*models.TaskPlan, error) {
if req.TaskID == "" {
return nil, ErrTaskIDRequired
}
if req.SessionID == "" {
return nil, ErrSessionIDRequired
}
if err := s.authorize(ctx, req.TaskID); err != nil {
return nil, err
}
session, err := s.repo.GetTaskSession(ctx, req.SessionID)
if err != nil {
if errors.Is(err, models.ErrTaskSessionNotFound) {
return nil, ErrSessionTaskMismatch
}
return nil, err
}
if session == nil || session.TaskID != req.TaskID {
return nil, ErrSessionTaskMismatch
}
actor := req.Actor
if actor == "" {
actor = createdByUser
}
plan, err := s.repo.MarkTaskPlanImplementationStarted(ctx, req.TaskID, req.SessionID, actor)
if err != nil {
if errors.Is(err, repository.ErrTaskPlanNotFound) {
return nil, ErrTaskPlanNotFound
}
return nil, err
}
if plan == nil {
return nil, ErrTaskPlanNotFound
}
s.publishPlanEvent(ctx, events.TaskPlanUpdated, plan)
return plan, nil
}
// DeletePlan removes a plan and all its revisions (cascade via FK when task goes; here we delete only HEAD).
// Historical revisions remain for audit; callers wanting a full wipe should delete the task.
func (s *PlanService) DeletePlan(ctx context.Context, taskID string) error {
if taskID == "" {
return ErrTaskIDRequired
}
if err := s.authorize(ctx, taskID); err != nil {
return err
}
existing, err := s.repo.GetTaskPlan(ctx, taskID)
if err != nil {
return err
}
if existing == nil {
return ErrTaskPlanNotFound
}
if err := s.repo.DeleteTaskPlan(ctx, taskID); err != nil {
return err
}
s.publishPlanEvent(ctx, events.TaskPlanDeleted, existing)
return nil
}
// ListRevisions returns plan revisions newest-first without content (metadata only).
func (s *PlanService) ListRevisions(ctx context.Context, taskID string) ([]*models.TaskPlanRevision, error) {
if taskID == "" {
return nil, ErrTaskIDRequired
}
if err := s.authorize(ctx, taskID); err != nil {
return nil, err
}
return s.repo.ListTaskPlanRevisions(ctx, taskID, 0)
}
// GetRevision returns a single revision with content (for diff/preview).
func (s *PlanService) GetRevision(ctx context.Context, id string) (*models.TaskPlanRevision, error) {
rev, err := s.repo.GetTaskPlanRevision(ctx, id)
if err != nil {
return nil, err
}
if rev == nil {
return nil, ErrRevisionNotFound
}
if err := s.authorize(ctx, rev.TaskID); err != nil {
return nil, err
}
return rev, nil
}
// RevertPlanRequest parameters for a revert-to-revision operation.
type RevertPlanRequest struct {
TaskID string
TargetRevisionID string
AuthorName string // user display name; "User" fallback when empty
}
// RevertPlan creates a new revision whose content mirrors the target and updates HEAD,
// atomically via WritePlanRevision. Revert revisions are never coalesced (the "restored
// from vK" marker is preserved).
func (s *PlanService) RevertPlan(ctx context.Context, req RevertPlanRequest) (*models.TaskPlanRevision, error) {
if req.TaskID == "" {
return nil, ErrTaskIDRequired
}
if req.TargetRevisionID == "" {
return nil, ErrRevisionIDRequired
}
if err := s.authorize(ctx, req.TaskID); err != nil {
return nil, err
}
target, err := s.repo.GetTaskPlanRevision(ctx, req.TargetRevisionID)
if err != nil {
return nil, err
}
if target == nil {
return nil, ErrRevisionNotFound
}
if target.TaskID != req.TaskID {
return nil, ErrRevisionTaskMismatch
}
authorName := req.AuthorName
if authorName == "" {
authorName = defaultUserAuthorFallback
}
head, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
return nil, err
}
plan := &models.TaskPlan{
TaskID: req.TaskID,
Title: target.Title,
Content: target.Content,
CreatedBy: createdByUser,
}
if head != nil {
plan.ID = head.ID
plan.CreatedAt = head.CreatedAt
}
targetID := target.ID
rev := &models.TaskPlanRevision{
TaskID: req.TaskID,
Title: target.Title,
Content: target.Content,
AuthorKind: createdByUser,
AuthorName: authorName,
RevertOfRevisionID: &targetID,
}
if err := s.repo.WritePlanRevision(ctx, plan, rev, nil); err != nil {
return nil, err
}
saved, err := s.repo.GetTaskPlan(ctx, req.TaskID)
if err != nil {
return nil, err
}
if saved == nil {
return nil, ErrTaskPlanNotFound
}
s.publishPlanEvent(ctx, events.TaskPlanUpdated, saved)
s.publishRevisionEvent(ctx, rev, false)
s.publishReverted(ctx, rev)
return rev, nil
}
func (s *PlanService) publishPlanEvent(ctx context.Context, eventType string, plan *models.TaskPlan) {
if s.eventBus == nil {
return
}
payload := map[string]interface{}{
"id": plan.ID,
"task_id": plan.TaskID,
"title": plan.Title,
"content": plan.Content,
"created_by": plan.CreatedBy,
"created_at": plan.CreatedAt,
"updated_at": plan.UpdatedAt,
}
if plan.ImplementationStartedAt != nil {
payload["implementation_started_at"] = *plan.ImplementationStartedAt
}
if plan.ImplementationStartedSessionID != nil {
payload["implementation_started_session_id"] = *plan.ImplementationStartedSessionID
}
if plan.ImplementationStartedBy != nil {
payload["implementation_started_by"] = *plan.ImplementationStartedBy
}
if err := s.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "plan-service", payload)); err != nil {
s.logger.Error("publish plan event", zap.String("event_type", eventType), zap.Error(err))
}
}
func (s *PlanService) publishRevisionEvent(ctx context.Context, rev *models.TaskPlanRevision, coalesced bool) {
if s.eventBus == nil {
return
}
payload := revisionPayload(rev)
payload["coalesced"] = coalesced
if err := s.eventBus.Publish(ctx, events.TaskPlanRevisionCreated, bus.NewEvent(events.TaskPlanRevisionCreated, "plan-service", payload)); err != nil {
s.logger.Error("publish revision event", zap.Error(err))
}
}
func (s *PlanService) publishReverted(ctx context.Context, rev *models.TaskPlanRevision) {
if s.eventBus == nil {
return
}
payload := revisionPayload(rev)
if err := s.eventBus.Publish(ctx, events.TaskPlanReverted, bus.NewEvent(events.TaskPlanReverted, "plan-service", payload)); err != nil {
s.logger.Error("publish reverted event", zap.Error(err))
}
}
func revisionPayload(rev *models.TaskPlanRevision) map[string]interface{} {
p := map[string]interface{}{
"id": rev.ID,
"task_id": rev.TaskID,
"revision_number": rev.RevisionNumber,
"title": rev.Title,
"author_kind": rev.AuthorKind,
"author_name": rev.AuthorName,
"created_at": rev.CreatedAt,
"updated_at": rev.UpdatedAt,
}
if rev.RevertOfRevisionID != nil {
p["revert_of_revision_id"] = *rev.RevertOfRevisionID
}
return p
}