876 lines
40 KiB
Go
876 lines
40 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/office/models"
|
|
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
"github.com/kandev/kandev/internal/office/routing"
|
|
"github.com/kandev/kandev/internal/office/shared"
|
|
runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// DB-state constants. These mirror the values stored in the
|
|
// tasks.state column and are used in several places (status
|
|
// normalisation, gate logic, supersede branches).
|
|
const (
|
|
stateTODO = "TODO"
|
|
stateInProgress = "IN_PROGRESS"
|
|
stateInReview = "REVIEW"
|
|
stateBlocked = "BLOCKED"
|
|
stateCompleted = "COMPLETED"
|
|
stateCancelled = "CANCELLED"
|
|
stateBacklog = "BACKLOG"
|
|
)
|
|
|
|
// statusInReviewLowercase is the lowercase canonical form returned to
|
|
// clients in the ApprovalsPendingError redirect payload.
|
|
const statusInReviewLowercase = "in_review"
|
|
|
|
// Lowercase canonical status strings returned to / accepted from clients.
|
|
// Each pairs with its uppercase DB state above.
|
|
const (
|
|
statusTODOLowercase = "todo"
|
|
statusCancelledLowercase = "cancelled"
|
|
statusDoneLowercase = "done"
|
|
statusInProgressLowercase = "in_progress"
|
|
statusReviewLowercase = "review"
|
|
statusBlockedLowercase = "blocked"
|
|
statusBacklogLowercase = "backlog"
|
|
)
|
|
|
|
// userSentinel is the decider_id used for the singleton human user
|
|
// in the decisions table. Pulled out as a constant since it is also
|
|
// the actor_type label for activity log entries.
|
|
const userSentinel = "user"
|
|
|
|
// Repository is the persistence interface required by DashboardService.
|
|
type Repository interface {
|
|
CountPendingApprovals(ctx context.Context, workspaceID string) (int, error)
|
|
ListActivityEntries(ctx context.Context, workspaceID string, limit int) ([]*models.ActivityEntry, error)
|
|
ListActivityEntriesByAction(ctx context.Context, workspaceID, action string, limit int) ([]*models.ActivityEntry, error)
|
|
ListActivityEntriesByTarget(ctx context.Context, workspaceID, targetID string, limit int) ([]*models.ActivityEntry, error)
|
|
ListPendingApprovals(ctx context.Context, workspaceID string) ([]*models.Approval, error)
|
|
ListRuns(ctx context.Context, workspaceID string) ([]*models.Run, error)
|
|
SearchTasks(ctx context.Context, workspaceID, query string, limit int) ([]*sqlite.TaskSearchResult, error)
|
|
ListActivityEntriesByType(ctx context.Context, workspaceID, filterType string, limit int) ([]*models.ActivityEntry, error)
|
|
ListTasksByWorkspace(ctx context.Context, workspaceID string, includeSystem bool) ([]*sqlite.TaskRow, error)
|
|
ListTasksFiltered(ctx context.Context, workspaceID string, opts sqlite.ListTasksOptions) (*sqlite.ListTasksFilteredResult, error)
|
|
GetTaskByID(ctx context.Context, taskID string) (*sqlite.TaskRow, error)
|
|
ListChildTasks(ctx context.Context, parentID string) ([]*sqlite.TaskRow, error)
|
|
ListBlockersForTasks(ctx context.Context, taskIDs []string) (map[string][]string, error)
|
|
ListTaskComments(ctx context.Context, taskID string) ([]*models.TaskComment, error)
|
|
CreateTaskComment(ctx context.Context, comment *models.TaskComment) error
|
|
GetRunsByCommentIDs(ctx context.Context, commentIDs []string) (map[string]sqlite.CommentRunStatus, error)
|
|
UpdateTaskState(ctx context.Context, taskID, state string) error
|
|
GetTaskExecutionFields(ctx context.Context, taskID string) (*sqlite.TaskExecutionFields, error)
|
|
// UpdateTaskAssignee returns the task's assignment_generation after the
|
|
// bump, read back inside the same transaction that wrote the runner
|
|
// seat (see the sqlite implementation's doc comment).
|
|
UpdateTaskAssignee(ctx context.Context, taskID, assigneeID string) (int64, error)
|
|
UpdateTaskStateIfWorkflowStep(ctx context.Context, taskID, expectedStepID, state string) (bool, error)
|
|
UpdateTaskPriority(ctx context.Context, taskID, priority string) error
|
|
UpdateTaskProjectID(ctx context.Context, taskID, projectID string) error
|
|
GetTaskProjectID(ctx context.Context, taskID string) (string, error)
|
|
UpdateTaskParentID(ctx context.Context, taskID, parentID string) error
|
|
GetProjectWorkspaceID(ctx context.Context, projectID string) (string, error)
|
|
GetTaskWorkspaceID(ctx context.Context, taskID string) (string, error)
|
|
CountTasksByWorkspace(ctx context.Context, workspaceID string) (int, error)
|
|
QueryRunActivity(ctx context.Context, workspaceID string, days int) ([]sqlite.RunActivityRow, error)
|
|
QueryTaskBreakdown(ctx context.Context, workspaceID string) ([]sqlite.TaskBreakdownRow, error)
|
|
QueryRecentTasks(ctx context.Context, workspaceID string, limit int) ([]sqlite.RecentTaskRow, error)
|
|
QueryRecentSessions(ctx context.Context, workspaceID string, limit int) ([]sqlite.LiveSessionRow, error)
|
|
ListRecentSessionsByAgentBatch(ctx context.Context, agentInstanceIDs []string, perAgentLimit int) (map[string][]sqlite.AgentSessionRow, error)
|
|
CountToolCallMessagesBySession(ctx context.Context, sessionIDs []string) (map[string]int, error)
|
|
GetTasksByIDs(ctx context.Context, ids []string) ([]sqlite.TaskTitleRow, error)
|
|
CreateTaskBlocker(ctx context.Context, blocker *models.TaskBlocker) error
|
|
DeleteTaskBlocker(ctx context.Context, taskID, blockerTaskID string) error
|
|
ListTaskBlockers(ctx context.Context, taskID string) ([]*models.TaskBlocker, error)
|
|
ListTaskParticipants(ctx context.Context, taskID, role string) ([]sqlite.Participant, error)
|
|
ListAllTaskParticipants(ctx context.Context, taskID string) ([]sqlite.Participant, error)
|
|
AddTaskParticipant(ctx context.Context, taskID, agentID, role string) (sqlite.ParticipantWriteResult, error)
|
|
RemoveTaskParticipant(ctx context.Context, taskID, agentID, role string) error
|
|
GetTaskWorkflowStepID(ctx context.Context, taskID string) (string, error)
|
|
// CancelDisplacedParticipantRun cancels the run(s) the step-entry
|
|
// fan-out queued for agentProfileID at (taskID, stepID). Used after a
|
|
// claim displaces an agent from a role. Returns the rows actually
|
|
// cancelled so the caller can record each one's loop-liveness
|
|
// terminal shape.
|
|
CancelDisplacedParticipantRun(
|
|
ctx context.Context, taskID, stepID, agentProfileID string,
|
|
) ([]runssqlite.CancelledRun, error)
|
|
IsTaskWorkflowStepTerminal(ctx context.Context, taskID string) (terminal, hasStep bool, err error)
|
|
}
|
|
|
|
// DecisionStore is the workflow-domain decisions interface required by
|
|
// DashboardService. Implementations route to workflow_step_decisions.
|
|
// ADR 0005 Wave E moved decisions off the legacy office_task_approval_decisions
|
|
// table; this interface lives at the dashboard tier so the package does
|
|
// not import the workflow repository directly.
|
|
type DecisionStore interface {
|
|
// FindParticipantID resolves the participant id for the natural key
|
|
// (step_id, task_id, role, agent_profile_id). Empty agent_profile_id
|
|
// (singleton user) returns "" without an error.
|
|
FindParticipantID(ctx context.Context, stepID, taskID, role, agentProfileID string) (string, error)
|
|
// RecordStepDecision atomically supersedes any prior active decision
|
|
// matching the natural key (task, step, decider_id, role) — falling back
|
|
// to (task, step, participant_id) when decider info is empty — and
|
|
// inserts a fresh non-superseded row.
|
|
RecordStepDecision(ctx context.Context, d *workflowmodels.WorkflowStepDecision) error
|
|
// ListActiveTaskDecisions returns every non-superseded decision for a
|
|
// task across all steps, oldest first.
|
|
ListActiveTaskDecisions(ctx context.Context, taskID string) ([]*workflowmodels.WorkflowStepDecision, error)
|
|
// SupersedeTaskDecisions marks every active decision for a task as
|
|
// superseded across all steps.
|
|
SupersedeTaskDecisions(ctx context.Context, taskID string) error
|
|
}
|
|
|
|
// SkillLister returns the list of skills for a workspace.
|
|
type SkillLister interface {
|
|
ListSkills(ctx context.Context, wsID string) ([]*models.Skill, error)
|
|
}
|
|
|
|
// RoutineLister returns the list of routines for a workspace.
|
|
type RoutineLister interface {
|
|
ListRoutines(ctx context.Context, wsID string) ([]*models.Routine, error)
|
|
}
|
|
|
|
// GovernanceSettingsStore persists workspace governance settings.
|
|
type GovernanceSettingsStore interface {
|
|
GetRequireApprovalForNewAgents(ctx context.Context, workspaceID string) (bool, error)
|
|
SetRequireApprovalForNewAgents(ctx context.Context, workspaceID string, required bool) error
|
|
GetRequireApprovalForTaskCompletion(ctx context.Context, workspaceID string) (bool, error)
|
|
SetRequireApprovalForTaskCompletion(ctx context.Context, workspaceID string, required bool) error
|
|
GetRequireApprovalForSkillChanges(ctx context.Context, workspaceID string) (bool, error)
|
|
SetRequireApprovalForSkillChanges(ctx context.Context, workspaceID string, required bool) error
|
|
}
|
|
|
|
// RetryCanceller is the interface used to cancel pending retries when a task is reassigned.
|
|
type RetryCanceller interface {
|
|
CancelPendingRetriesForTask(ctx context.Context, taskID string) error
|
|
}
|
|
|
|
// TerminalShapeRecorder classifies and counts a loop-liveness terminal
|
|
// shape (office_loop_terminal_total, REQ-OFFICE-LOOP-LIVENESS-005) for
|
|
// runs the dashboard package cancelled directly. Mirrors the RetryCanceller
|
|
// / TaskCanceller pattern above: the classification logic (activation
|
|
// instant, workspace resolution) is stateful and already lives once on
|
|
// *office/service.Service, so this seam reuses it instead of a second,
|
|
// divergence-prone copy in dashboard.
|
|
type TerminalShapeRecorder interface {
|
|
RecordCancelledRunTerminalShapes(ctx context.Context, cancelled []runssqlite.CancelledRun)
|
|
}
|
|
|
|
// ProjectBudgetEvaluator evaluates project-scoped budget policies for a
|
|
// destination project. Wired to costs.CostService.EvaluateProjectBudget so
|
|
// reassigning a task into a project re-checks that project's policies —
|
|
// otherwise a notify_only threshold crossed purely by moving historical
|
|
// spend (not a new cost event or agent launch) would never fire an alert.
|
|
type ProjectBudgetEvaluator interface {
|
|
EvaluateProjectBudget(ctx context.Context, workspaceID, projectID string) error
|
|
}
|
|
|
|
// RunResolver resolves the originating office run id for a task, used
|
|
// to attribute the task_status_changed activity row back to the run
|
|
// that produced the transition. Optional dependency — when nil, the
|
|
// activity row is logged with an empty run_id. Mirrors channels.RunResolver.
|
|
type RunResolver interface {
|
|
ResolveRunForTask(ctx context.Context, taskID string) string
|
|
}
|
|
|
|
// TaskCanceller hard-cancels a task's active execution. Used by the
|
|
// reactivity pipeline when a task is moved to "cancelled" status.
|
|
type TaskCanceller interface {
|
|
CancelTaskExecution(ctx context.Context, taskID, reason string, force bool) error
|
|
}
|
|
|
|
// HumanAssigneeWriter applies a human assignee on behalf of the calling user.
|
|
//
|
|
// Both the authorization (the caller needs task.write on the workspace) and
|
|
// the validation (the assignee must be able to reach it) are owned by the task
|
|
// service. Office delegates the whole write rather than reimplementing either,
|
|
// which also covers a gap specific to this route: PATCH /office/tasks/:id
|
|
// carries no `:wsId`, so the office workspace-scope middleware does not gate
|
|
// it.
|
|
type HumanAssigneeWriter interface {
|
|
SetHumanAssignee(ctx context.Context, taskID, userID string) error
|
|
}
|
|
|
|
// TaskDetacher applies the canonical task hierarchy/workspace detachment and
|
|
// publishes the task lifecycle and Office refresh events.
|
|
type TaskDetacher interface {
|
|
DetachTask(ctx context.Context, taskID string) (*taskmodels.Task, error)
|
|
}
|
|
|
|
// TaskLifecyclePublisher reloads and publishes the canonical task.updated
|
|
// event for a task row Office has just mutated. The implementation owns task
|
|
// publication ordering so concurrent status writes cannot publish stale
|
|
// snapshots. Office's own status-change events only reach the Office board.
|
|
type TaskLifecyclePublisher interface {
|
|
PublishTaskUpdatedByID(ctx context.Context, id string)
|
|
}
|
|
|
|
// SessionTerminator flips the (task, agent) office session row to a terminal
|
|
// state. Used when an agent stops being a participant on a task — reassignment,
|
|
// reviewer/approver removal, or agent instance deletion. Idempotent: skipping
|
|
// the call when no matching row exists or the row is already terminal is the
|
|
// caller's responsibility (or the implementation's, depending on flavour).
|
|
type SessionTerminator interface {
|
|
TerminateOfficeSession(ctx context.Context, taskID, agentInstanceID, reason string) error
|
|
}
|
|
|
|
// FailureNotifier is invoked when a task's assignee changes so the
|
|
// failure-tracking layer can auto-dismiss any inbox entries tied to
|
|
// the prior (task, agent) pair. The agent's consecutive-failure
|
|
// counter is intentionally NOT reset by reassignment.
|
|
type FailureNotifier interface {
|
|
OnAssigneeChanged(ctx context.Context, taskID, oldAgentID string)
|
|
}
|
|
|
|
// FailureInboxRow is a slim, dashboard-package view of one failed
|
|
// run or one auto-paused agent. The Office service flattens its
|
|
// repo rows into this shape so the dashboard package doesn't need to
|
|
// import the office repo types.
|
|
type FailureInboxRow struct {
|
|
Kind string
|
|
ItemID string
|
|
AgentProfileID string
|
|
AgentName string
|
|
TaskID string
|
|
ErrorMessage string
|
|
PauseReason string
|
|
ConsecutiveFailures int
|
|
FailedAt time.Time
|
|
}
|
|
|
|
// FailureInboxSource exposes the failed-run + auto-paused-agent
|
|
// sources used by the inbox.
|
|
type FailureInboxSource interface {
|
|
ListFailedRunInboxRows(ctx context.Context, workspaceID, userID string) ([]FailureInboxRow, error)
|
|
ListPausedAgentInboxRows(ctx context.Context, workspaceID, userID string) ([]FailureInboxRow, error)
|
|
}
|
|
|
|
// MarkFixedHandler resolves an inbox dismissal: removes the row and
|
|
// re-queues the appropriate run(s).
|
|
type MarkFixedHandler interface {
|
|
MarkAgentRunFailedFixed(ctx context.Context, userID, runID string) error
|
|
MarkAgentPausedFixed(ctx context.Context, userID, agentID string) error
|
|
}
|
|
|
|
// TaskReactivityChange is a slim, dashboard-package view of the change
|
|
// being applied. Mirrored in scheduler.TaskMutation by the adapter so
|
|
// the dashboard package doesn't import scheduler types.
|
|
type TaskReactivityChange struct {
|
|
NewStatus *string
|
|
NewAssigneeID *string
|
|
// AssignmentGeneration is the value UpdateTaskAssignee's transaction
|
|
// committed and read back, carried here rather than re-read. Nil means
|
|
// the caller could not supply one (e.g. the read-back itself failed);
|
|
// the pipeline then enqueues the task_assigned wake keyless.
|
|
AssignmentGeneration *int64
|
|
// PrevAssigneeID is the assignee BEFORE the mutation. Required when
|
|
// NewAssigneeID is set so the pipeline can detect a real change and
|
|
// hand off the previous assignee's session.
|
|
PrevAssigneeID string
|
|
Comment *TaskReactivityComment
|
|
ReopenIntent bool
|
|
ResumeIntent bool
|
|
// SkipAssigneeCommentWake suppresses only the legacy assignee
|
|
// task_comment wake. Mention fan-out still runs so @mentions on
|
|
// the same comment remain additive.
|
|
SkipAssigneeCommentWake bool
|
|
ActorID string
|
|
ActorType string
|
|
}
|
|
|
|
// TaskReactivityComment is the slim view of a comment for the reactivity pipeline.
|
|
type TaskReactivityComment struct {
|
|
ID string
|
|
Body string
|
|
AuthorType string
|
|
AuthorID string
|
|
}
|
|
|
|
// TaskReactivityResult summarises pipeline side-effects (kept thin so
|
|
// the dashboard package doesn't depend on scheduler types).
|
|
type TaskReactivityResult struct {
|
|
InterruptSessionID string
|
|
}
|
|
|
|
// ReactivityApplier runs the office task reactivity pipeline. Implemented
|
|
// by an adapter around scheduler.SchedulerService so the dashboard
|
|
// package doesn't import the scheduler package.
|
|
type ReactivityApplier interface {
|
|
ApplyTaskMutation(
|
|
ctx context.Context,
|
|
taskID string,
|
|
preStatus string,
|
|
change TaskReactivityChange,
|
|
) (*TaskReactivityResult, error)
|
|
}
|
|
|
|
// ApprovalRun describes a single run the approval-flow service
|
|
// wants to queue for an agent. Dashboard ships these to the scheduler
|
|
// via the ApprovalReactivityQueuer adapter so the dashboard package
|
|
// doesn't depend on scheduler types.
|
|
type ApprovalRun struct {
|
|
AgentID string
|
|
Reason string
|
|
TaskID string
|
|
WorkspaceID string
|
|
ActorID string
|
|
ActorType string
|
|
Role string // reviewer|approver, when relevant
|
|
DecisionComment string // for changes_requested/rejected
|
|
// IdempotencyKey identifies the decision event that caused this wake.
|
|
// Decision reactivity can recur for the same task, so the scheduler
|
|
// must not fall back to its once-per-task reason key.
|
|
IdempotencyKey string
|
|
}
|
|
|
|
// ApprovalReactivityQueuer queues approval-flow runs (review
|
|
// requested, changes requested, ready to close). Implemented by a
|
|
// scheduler adapter; the dashboard package only sees this small
|
|
// interface so it doesn't import scheduler types.
|
|
type ApprovalReactivityQueuer interface {
|
|
QueueApprovalRuns(ctx context.Context, runs []ApprovalRun) error
|
|
}
|
|
|
|
// RoutingProvider exposes provider-routing operations to the dashboard
|
|
// HTTP layer. Implemented by routing.Provider (in the routing package)
|
|
// so the dashboard package stays repo-agnostic and the resolver lives
|
|
// next to the rest of the routing code.
|
|
type RoutingProvider interface {
|
|
GetConfig(ctx context.Context, workspaceID string) (*routing.WorkspaceConfig, []routing.ProviderID, error)
|
|
ListExecutionProfiles(ctx context.Context, workspaceID string) ([]routing.ExecutionProfileSummary, error)
|
|
UpdateConfig(ctx context.Context, workspaceID string, cfg routing.WorkspaceConfig) error
|
|
Retry(ctx context.Context, workspaceID, providerID string) (string, *time.Time, error)
|
|
Health(ctx context.Context, workspaceID string) ([]models.ProviderHealth, error)
|
|
Preview(ctx context.Context, workspaceID string) ([]routing.PreviewItem, error)
|
|
PreviewAgent(ctx context.Context, agentID string) (*routing.PreviewItem, error)
|
|
// AgentOverrides returns the raw routing override blob persisted on
|
|
// the agent's settings JSON. The agent routing UI hydrates from
|
|
// this so toggles + tier override + provider-order override reflect
|
|
// the persisted state on first paint instead of always defaulting
|
|
// to "inherit". Returns the zero blob when the agent has no
|
|
// overrides recorded; an error only on lookup / parse failures.
|
|
AgentOverrides(ctx context.Context, agentID string) (routing.AgentOverrides, error)
|
|
}
|
|
|
|
// RouteAttemptLister is the narrow read-side seam used by the run-detail
|
|
// + standalone attempts endpoints to fetch the route_attempts list for a
|
|
// run. Implemented by *sqlite.Repository.
|
|
type RouteAttemptLister interface {
|
|
ListRouteAttempts(ctx context.Context, runID string) ([]models.RouteAttempt, error)
|
|
}
|
|
|
|
// SettingsProvider reads and writes workspace settings for the dashboard.
|
|
// Implemented by a thin adapter around the configloader.ConfigLoader.
|
|
type SettingsProvider interface {
|
|
// GetSettings returns the workspace settings for the named workspace.
|
|
// Returns nil, nil when the workspace does not exist.
|
|
GetSettings(workspaceName string) (*WorkspaceSettings, error)
|
|
// UpdatePermissionHandlingMode persists a new permission_handling_mode.
|
|
UpdatePermissionHandlingMode(workspaceName, mode string) error
|
|
// UpdateRecoveryLookbackHours persists the recovery_lookback_hours setting.
|
|
UpdateRecoveryLookbackHours(workspaceName string, hours int) error
|
|
}
|
|
|
|
// WorkspaceSettings is a simplified view of workspace-level settings
|
|
// exposed to the dashboard.
|
|
type WorkspaceSettings struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
PermissionHandlingMode string `json:"permission_handling_mode"`
|
|
RecoveryLookbackHours int `json:"recovery_lookback_hours"`
|
|
RequireApprovalForNewAgents bool `json:"require_approval_for_new_agents"`
|
|
RequireApprovalForTaskCompletion bool `json:"require_approval_for_task_completion"`
|
|
RequireApprovalForSkillChanges bool `json:"require_approval_for_skill_changes"`
|
|
}
|
|
|
|
// DashboardService provides dashboard, inbox, activity, run, and task-search business logic.
|
|
type DashboardService struct {
|
|
repo Repository
|
|
logger *logger.Logger
|
|
activity shared.ActivityLogger
|
|
agents shared.AgentReader
|
|
costs shared.CostChecker
|
|
permissions shared.PermissionLister // optional; nil means no permission items in inbox
|
|
settingsProvider SettingsProvider // optional; nil means settings endpoints are unavailable
|
|
governanceStore GovernanceSettingsStore // optional; nil means governance settings are unavailable
|
|
eb bus.EventBus // optional; nil means no events are published
|
|
retryCanceller RetryCanceller // optional; nil means retries are not cancelled on reassign
|
|
terminalShapeRecorder TerminalShapeRecorder // optional; nil means displaced-run cancellations aren't counted in office_loop_terminal_total
|
|
taskCanceller TaskCanceller // optional; used to hard-cancel sessions on status→cancelled
|
|
taskDetacher TaskDetacher // optional; canonical empty-parent mutation
|
|
taskLifecycle TaskLifecyclePublisher // optional; nil means status changes don't publish canonical task.updated
|
|
sessionTerm SessionTerminator // optional; flips office session rows to COMPLETED on participation removal
|
|
reactivity ReactivityApplier // optional; runs the office reactivity pipeline on mutations
|
|
engineDispatcher shared.WorkflowEngineDispatcher // optional; synchronously routes comment triggers through the engine
|
|
approvalQueuer ApprovalReactivityQueuer // optional; queues approval-flow runs
|
|
skillLister SkillLister // optional; nil means skill_count is always 0
|
|
routineLister RoutineLister // optional; nil means routine_count is always 0
|
|
failureNotifier FailureNotifier // optional; nil means assignee changes don't auto-dismiss inbox entries
|
|
failureInbox FailureInboxSource // optional; nil disables the new agent_run_failed / agent_paused_after_failures inbox sources
|
|
markFixed MarkFixedHandler // optional; nil disables the dismiss endpoint
|
|
decisions DecisionStore // workflow-domain decisions store (ADR 0005 Wave E); nil disables decision endpoints
|
|
routingProvider RoutingProvider // optional; nil disables /routing endpoints (503)
|
|
attemptLister RouteAttemptLister // optional; nil disables attempt embedding on run-detail responses
|
|
runResolver RunResolver // optional; nil means status-change activity rows have no run_id
|
|
assigneeWriter HumanAssigneeWriter // optional; nil rejects human-assignee writes rather than skipping authorization
|
|
projectBudget ProjectBudgetEvaluator // optional; nil means reassignment doesn't re-evaluate the destination project's budget policies
|
|
// officeSessionIdentity gates RecordAgentDecision's use of the caller's
|
|
// own session id. Defaults false (zero value); set via
|
|
// SetOfficeSessionIdentity, wired from features.officeSessionIdentity.
|
|
officeSessionIdentity bool
|
|
}
|
|
|
|
// SetOfficeSessionIdentity wires the features.officeSessionIdentity flag.
|
|
// When true, RecordAgentDecision forwards the decider's own calling session
|
|
// id so RecordDecision re-evaluates against it instead of the task's
|
|
// most-recently-started ("active") session. Defaults false.
|
|
func (s *DashboardService) SetOfficeSessionIdentity(enabled bool) {
|
|
s.officeSessionIdentity = enabled
|
|
}
|
|
|
|
// SetRoutingProvider wires the provider-routing seam used by the
|
|
// /workspaces/:wsId/routing/* endpoints. Optional; when nil, the
|
|
// HTTP handlers respond with 503.
|
|
func (s *DashboardService) SetRoutingProvider(p RoutingProvider) {
|
|
s.routingProvider = p
|
|
}
|
|
|
|
// SetRouteAttemptLister wires the read-side seam used to embed
|
|
// route_attempts on the run-detail response and back the standalone
|
|
// GET /runs/:id/attempts endpoint. Optional; nil means the run-detail
|
|
// payload omits the RouteAttempts field and /attempts responds 503.
|
|
func (s *DashboardService) SetRouteAttemptLister(l RouteAttemptLister) {
|
|
s.attemptLister = l
|
|
}
|
|
|
|
// RoutingProvider returns the wired routing provider, or nil when no
|
|
// provider has been set. Exposed so the HTTP handler can short-circuit.
|
|
func (s *DashboardService) RoutingProviderImpl() RoutingProvider {
|
|
return s.routingProvider
|
|
}
|
|
|
|
// RouteAttemptListerImpl returns the wired attempts lister, or nil.
|
|
func (s *DashboardService) RouteAttemptListerImpl() RouteAttemptLister {
|
|
return s.attemptLister
|
|
}
|
|
|
|
// EventBus returns the wired event bus (may be nil). Exposed so the
|
|
// HTTP layer can publish routing_settings_updated without re-reading
|
|
// the bus reference at every call site.
|
|
func (s *DashboardService) EventBus() bus.EventBus { return s.eb }
|
|
|
|
// SetDecisionStore wires the workflow-domain decisions store. Required for
|
|
// approve/request-changes/list endpoints; the service surfaces an
|
|
// internal error if any decision method is invoked without a store wired.
|
|
func (s *DashboardService) SetDecisionStore(d DecisionStore) {
|
|
s.decisions = d
|
|
}
|
|
|
|
// SetFailureInboxSource wires the source for the new
|
|
// agent_run_failed + agent_paused_after_failures inbox rows.
|
|
func (s *DashboardService) SetFailureInboxSource(src FailureInboxSource) {
|
|
s.failureInbox = src
|
|
}
|
|
|
|
// SetMarkFixedHandler wires the resolver for inbox "Mark fixed"
|
|
// actions. The handler dismisses the entry and re-queues the
|
|
// appropriate run(s).
|
|
func (s *DashboardService) SetMarkFixedHandler(h MarkFixedHandler) {
|
|
s.markFixed = h
|
|
}
|
|
|
|
// MarkFixedHandler returns the wired handler (or nil) so the HTTP
|
|
// layer can short-circuit when the dependency isn't configured.
|
|
func (s *DashboardService) MarkFixedHandler() MarkFixedHandler {
|
|
return s.markFixed
|
|
}
|
|
|
|
// SetFailureNotifier wires the failure-tracking notifier so reassignments
|
|
// auto-dismiss any inbox entries tied to the prior (task, agent) pair.
|
|
func (s *DashboardService) SetFailureNotifier(n FailureNotifier) {
|
|
s.failureNotifier = n
|
|
}
|
|
|
|
// NewDashboardService creates a new DashboardService.
|
|
func NewDashboardService(
|
|
repo Repository,
|
|
log *logger.Logger,
|
|
activity shared.ActivityLogger,
|
|
agents shared.AgentReader,
|
|
costs shared.CostChecker,
|
|
) *DashboardService {
|
|
return &DashboardService{
|
|
repo: repo,
|
|
logger: log.WithFields(zap.String("component", "office-dashboard")),
|
|
activity: activity,
|
|
agents: agents,
|
|
costs: costs,
|
|
}
|
|
}
|
|
|
|
// SetPermissionLister sets the source of pending permission/clarification requests
|
|
// used to populate inbox items. Call this after construction when the clarification
|
|
// store is available.
|
|
func (s *DashboardService) SetPermissionLister(lister shared.PermissionLister) {
|
|
s.permissions = lister
|
|
}
|
|
|
|
// SetSettingsProvider sets the provider used to read and write workspace settings.
|
|
// Call this after construction when the config loader is available.
|
|
func (s *DashboardService) SetSettingsProvider(p SettingsProvider) {
|
|
s.settingsProvider = p
|
|
}
|
|
|
|
// SetRetryCanceller sets the service used to cancel pending retries when a task is reassigned.
|
|
func (s *DashboardService) SetRetryCanceller(c RetryCanceller) {
|
|
s.retryCanceller = c
|
|
}
|
|
|
|
// SetTerminalShapeRecorder wires the seam used to count a loop-liveness
|
|
// terminal shape for a run this package cancelled directly (a displaced
|
|
// participant's queued run).
|
|
func (s *DashboardService) SetTerminalShapeRecorder(r TerminalShapeRecorder) {
|
|
s.terminalShapeRecorder = r
|
|
}
|
|
|
|
// SetRunResolver wires the seam used to attribute status-change activity
|
|
// rows back to the office run that produced them. Optional; when unset,
|
|
// rows are logged with an empty run_id.
|
|
func (s *DashboardService) SetRunResolver(r RunResolver) {
|
|
s.runResolver = r
|
|
}
|
|
|
|
// SetProjectBudgetEvaluator wires the seam used to re-evaluate a
|
|
// destination project's budget policies on task reassignment. Optional;
|
|
// when unset, UpdateTaskProjectID does not check budgets.
|
|
func (s *DashboardService) SetProjectBudgetEvaluator(e ProjectBudgetEvaluator) {
|
|
s.projectBudget = e
|
|
}
|
|
|
|
// LogTaskStateChange records a task state transition for Office tasks before
|
|
// the task service publishes its state-change notification. This keeps the
|
|
// activity-backed timeline durable before clients refetch the task detail.
|
|
// Non-Office tasks use the shared task service and are ignored here.
|
|
func (s *DashboardService) LogTaskStateChange(
|
|
ctx context.Context, task *taskmodels.Task, oldState v1.TaskState,
|
|
) {
|
|
if task == nil || oldState == task.State || s.activity == nil {
|
|
return
|
|
}
|
|
fields, err := s.repo.GetTaskExecutionFields(ctx, task.ID)
|
|
if err != nil || fields == nil || !fields.IsFromOffice {
|
|
return
|
|
}
|
|
wsID := fields.WorkspaceID
|
|
if wsID == "" {
|
|
wsID = task.WorkspaceID
|
|
}
|
|
runID := ""
|
|
if s.runResolver != nil {
|
|
runID = s.runResolver.ResolveRunForTask(ctx, task.ID)
|
|
}
|
|
details, _ := json.Marshal(map[string]string{
|
|
"new_status": string(task.State),
|
|
"old_status": string(oldState),
|
|
})
|
|
s.activity.LogActivityWithRun(ctx, wsID, "system", "orchestrator",
|
|
"task_status_changed", "task", task.ID, string(details), runID, "")
|
|
}
|
|
|
|
// SetTaskCanceller sets the canceller used to hard-cancel sessions when
|
|
// a task is moved to "cancelled".
|
|
func (s *DashboardService) SetTaskCanceller(c TaskCanceller) {
|
|
s.taskCanceller = c
|
|
}
|
|
|
|
// SetHumanAssigneeWriter wires the human-assignee write seam. It is optional
|
|
// only in the sense that office can be constructed without it; when it is
|
|
// absent the mutation is refused rather than applied unauthorized, because
|
|
// this route is not otherwise workspace-gated.
|
|
func (s *DashboardService) SetHumanAssigneeWriter(w HumanAssigneeWriter) {
|
|
s.assigneeWriter = w
|
|
}
|
|
|
|
// SetTaskDetacher wires the canonical task detachment operation used when the
|
|
// Office parent picker selects "No parent".
|
|
func (s *DashboardService) SetTaskDetacher(d TaskDetacher) {
|
|
s.taskDetacher = d
|
|
}
|
|
|
|
// SetTaskLifecyclePublisher wires the canonical task.updated publisher used
|
|
// after a status change persists a task row mutation.
|
|
func (s *DashboardService) SetTaskLifecyclePublisher(p TaskLifecyclePublisher) {
|
|
s.taskLifecycle = p
|
|
}
|
|
|
|
// SetSessionTerminator wires the office session terminator. Optional; when
|
|
// nil, participation removal won't flip the prior agent's session to a
|
|
// terminal state (the row stays IDLE/RUNNING and its conversation lingers).
|
|
func (s *DashboardService) SetSessionTerminator(t SessionTerminator) {
|
|
s.sessionTerm = t
|
|
}
|
|
|
|
// SetReactivityApplier sets the office reactivity pipeline. When set,
|
|
// every property mutation runs through it to queue downstream runs
|
|
// (blockers resolved, children completed, comments, mentions, etc.).
|
|
func (s *DashboardService) SetReactivityApplier(r ReactivityApplier) {
|
|
s.reactivity = r
|
|
}
|
|
|
|
// SetWorkflowEngineDispatcher wires the synchronous workflow-engine
|
|
// dispatcher used for dashboard-created comments.
|
|
func (s *DashboardService) SetWorkflowEngineDispatcher(d shared.WorkflowEngineDispatcher) {
|
|
s.engineDispatcher = d
|
|
}
|
|
|
|
// SetApprovalReactivityQueuer sets the queuer used to fire approval-flow
|
|
// runs (task_changes_requested, task_ready_to_close). When unset, the
|
|
// approval-flow service still records decisions but no runs fire.
|
|
func (s *DashboardService) SetApprovalReactivityQueuer(q ApprovalReactivityQueuer) {
|
|
s.approvalQueuer = q
|
|
}
|
|
|
|
// SetGovernanceStore sets the store used to read and write governance settings.
|
|
func (s *DashboardService) SetGovernanceStore(g GovernanceSettingsStore) {
|
|
s.governanceStore = g
|
|
}
|
|
|
|
// GetRequireApprovalForNewAgents returns the governance setting for new agent approval.
|
|
// Implements agents.GovernanceSettingsReader.
|
|
func (s *DashboardService) GetRequireApprovalForNewAgents(ctx context.Context, workspaceID string) (bool, error) {
|
|
if s.governanceStore == nil {
|
|
return false, nil
|
|
}
|
|
return s.governanceStore.GetRequireApprovalForNewAgents(ctx, workspaceID)
|
|
}
|
|
|
|
// GetWorkspaceSettings returns workspace settings including governance flags.
|
|
// Returns defaults when no settings provider has been configured.
|
|
func (s *DashboardService) GetWorkspaceSettings(ctx context.Context, workspaceName, workspaceID string) (*WorkspaceSettings, error) {
|
|
var ws *WorkspaceSettings
|
|
if s.settingsProvider == nil {
|
|
ws = &WorkspaceSettings{Name: workspaceName, PermissionHandlingMode: "human"}
|
|
} else {
|
|
var err error
|
|
ws, err = s.settingsProvider.GetSettings(workspaceName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ws == nil {
|
|
ws = &WorkspaceSettings{Name: workspaceName, PermissionHandlingMode: "human"}
|
|
}
|
|
}
|
|
if s.governanceStore != nil {
|
|
req, _ := s.governanceStore.GetRequireApprovalForNewAgents(ctx, workspaceID)
|
|
ws.RequireApprovalForNewAgents = req
|
|
tc, _ := s.governanceStore.GetRequireApprovalForTaskCompletion(ctx, workspaceID)
|
|
ws.RequireApprovalForTaskCompletion = tc
|
|
sc, _ := s.governanceStore.GetRequireApprovalForSkillChanges(ctx, workspaceID)
|
|
ws.RequireApprovalForSkillChanges = sc
|
|
}
|
|
return ws, nil
|
|
}
|
|
|
|
// UpdatePermissionHandlingMode updates the permission_handling_mode for a workspace.
|
|
func (s *DashboardService) UpdatePermissionHandlingMode(workspaceName, mode string) error {
|
|
if s.settingsProvider == nil {
|
|
return fmt.Errorf("settings provider not configured")
|
|
}
|
|
return s.settingsProvider.UpdatePermissionHandlingMode(workspaceName, mode)
|
|
}
|
|
|
|
// UpdateRecoveryLookbackHours updates the recovery_lookback_hours setting for a workspace.
|
|
func (s *DashboardService) UpdateRecoveryLookbackHours(workspaceName string, hours int) error {
|
|
if s.settingsProvider == nil {
|
|
return fmt.Errorf("settings provider not configured")
|
|
}
|
|
return s.settingsProvider.UpdateRecoveryLookbackHours(workspaceName, hours)
|
|
}
|
|
|
|
// UpdateGovernanceApprovalFlags updates the three governance approval toggle flags.
|
|
func (s *DashboardService) UpdateGovernanceApprovalFlags(
|
|
ctx context.Context, workspaceID string,
|
|
newAgents, taskCompletion, skillChanges *bool,
|
|
) {
|
|
if s.governanceStore == nil {
|
|
return
|
|
}
|
|
if newAgents != nil {
|
|
if err := s.governanceStore.SetRequireApprovalForNewAgents(ctx, workspaceID, *newAgents); err != nil {
|
|
s.logger.Warn("failed to update require_approval_for_new_agents", zap.Error(err))
|
|
}
|
|
}
|
|
if taskCompletion != nil {
|
|
if err := s.governanceStore.SetRequireApprovalForTaskCompletion(ctx, workspaceID, *taskCompletion); err != nil {
|
|
s.logger.Warn("failed to update require_approval_for_task_completion", zap.Error(err))
|
|
}
|
|
}
|
|
if skillChanges != nil {
|
|
if err := s.governanceStore.SetRequireApprovalForSkillChanges(ctx, workspaceID, *skillChanges); err != nil {
|
|
s.logger.Warn("failed to update require_approval_for_skill_changes", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ListBlockersForChildren returns a map of child task ID → blocker IDs for a set of children.
|
|
func (s *DashboardService) ListBlockersForChildren(ctx context.Context, childIDs []string) (map[string][]string, error) {
|
|
if len(childIDs) == 0 {
|
|
return map[string][]string{}, nil
|
|
}
|
|
return s.repo.ListBlockersForTasks(ctx, childIDs)
|
|
}
|
|
|
|
// SetEventBus configures the event bus used to publish task status change events.
|
|
// Call after construction when the event bus is available.
|
|
func (s *DashboardService) SetEventBus(eb bus.EventBus) {
|
|
s.eb = eb
|
|
}
|
|
|
|
// SetSkillLister sets the provider used to count skills for the dashboard.
|
|
func (s *DashboardService) SetSkillLister(sl SkillLister) {
|
|
s.skillLister = sl
|
|
}
|
|
|
|
// SetRoutineLister sets the provider used to count routines for the dashboard.
|
|
func (s *DashboardService) SetRoutineLister(rl RoutineLister) {
|
|
s.routineLister = rl
|
|
}
|
|
|
|
// ListActivityFiltered returns activity entries filtered by optional type.
|
|
func (s *DashboardService) ListActivityFiltered(ctx context.Context, wsID, filterType string, limit int) ([]*models.ActivityEntry, error) {
|
|
var (
|
|
entries []*models.ActivityEntry
|
|
err error
|
|
)
|
|
if filterType == "" || filterType == "all" {
|
|
entries, err = s.repo.ListActivityEntries(ctx, wsID, limit)
|
|
} else {
|
|
entries, err = s.repo.ListActivityEntriesByType(ctx, wsID, filterType, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.enrichActivityLabels(ctx, wsID, entries, nil)
|
|
return entries, nil
|
|
}
|
|
|
|
// ListActivityForTarget returns activity entries scoped to one target entity.
|
|
func (s *DashboardService) ListActivityForTarget(ctx context.Context, wsID, targetID string, limit int) ([]*models.ActivityEntry, error) {
|
|
if targetID == "" {
|
|
return s.ListActivityFiltered(ctx, wsID, "", limit)
|
|
}
|
|
return s.repo.ListActivityEntriesByTarget(ctx, wsID, targetID, limit)
|
|
}
|
|
|
|
// ListRuns returns run requests for a workspace.
|
|
func (s *DashboardService) ListRuns(ctx context.Context, wsID string) ([]*models.Run, error) {
|
|
return s.repo.ListRuns(ctx, wsID)
|
|
}
|
|
|
|
// SearchTasks searches for tasks matching the query string in a workspace.
|
|
func (s *DashboardService) SearchTasks(ctx context.Context, wsID, query string, limit int) ([]*sqlite.TaskSearchResult, error) {
|
|
return s.repo.SearchTasks(ctx, wsID, query, limit)
|
|
}
|
|
|
|
// ListTasks returns non-archived tasks for a workspace. When
|
|
// includeSystem is false (default for the Office Tasks UI) tasks
|
|
// belonging to kandev-managed system workflows are excluded.
|
|
func (s *DashboardService) ListTasks(ctx context.Context, wsID string, includeSystem bool) ([]*sqlite.TaskRow, error) {
|
|
return s.repo.ListTasksByWorkspace(ctx, wsID, includeSystem)
|
|
}
|
|
|
|
// ListTasksFiltered returns a paginated, optionally filtered slice of
|
|
// non-archived tasks for a workspace (Stream E of office optimization).
|
|
// See sqlite.ListTasksOptions for the supported filters; sort field is
|
|
// validated against an allow-list at the repository tier.
|
|
func (s *DashboardService) ListTasksFiltered(
|
|
ctx context.Context, wsID string, opts sqlite.ListTasksOptions,
|
|
) (*sqlite.ListTasksFilteredResult, error) {
|
|
return s.repo.ListTasksFiltered(ctx, wsID, opts)
|
|
}
|
|
|
|
// GetTask returns a single task by ID. Returns nil, nil when not found.
|
|
func (s *DashboardService) GetTask(ctx context.Context, taskID string) (*sqlite.TaskRow, error) {
|
|
return s.repo.GetTaskByID(ctx, taskID)
|
|
}
|
|
|
|
// ListChildTasks returns direct child tasks of a parent task.
|
|
func (s *DashboardService) ListChildTasks(ctx context.Context, parentID string) ([]*sqlite.TaskRow, error) {
|
|
return s.repo.ListChildTasks(ctx, parentID)
|
|
}
|
|
|
|
// ListComments returns all comments for a task.
|
|
func (s *DashboardService) ListComments(ctx context.Context, taskID string) ([]*models.TaskComment, error) {
|
|
return s.repo.ListTaskComments(ctx, taskID)
|
|
}
|
|
|
|
// GetRunsByCommentIDs returns the per-comment run snapshot for the
|
|
// supplied comment ids. Comments without a matching run are absent
|
|
// from the map. Used by the comments handler to attach run lifecycle
|
|
// state (id, status, error) to each comment DTO.
|
|
func (s *DashboardService) GetRunsByCommentIDs(
|
|
ctx context.Context, commentIDs []string,
|
|
) (map[string]sqlite.CommentRunStatus, error) {
|
|
return s.repo.GetRunsByCommentIDs(ctx, commentIDs)
|
|
}
|
|
|
|
// CreateComment creates a new comment on a task and runs the reactivity
|
|
// pipeline so the assignee (and any @-mentioned agents) wake up.
|
|
func (s *DashboardService) CreateComment(ctx context.Context, comment *models.TaskComment) error {
|
|
if err := s.repo.CreateTaskComment(ctx, comment); err != nil {
|
|
return err
|
|
}
|
|
engineHandled := s.dispatchCommentEngineTrigger(ctx, comment)
|
|
s.publishCommentCreated(ctx, comment, engineHandled)
|
|
s.runReactivityForComment(ctx, comment, engineHandled)
|
|
return nil
|
|
}
|
|
|
|
// ListStatusChanges returns status-change timeline events for a task, sourced
|
|
// from the office activity log. Returns an empty slice (never nil) on any
|
|
// error so callers can treat it as best-effort.
|
|
func (s *DashboardService) ListStatusChanges(ctx context.Context, workspaceID, taskID string) ([]TimelineEvent, error) {
|
|
entries, err := s.repo.ListActivityEntriesByTarget(ctx, workspaceID, taskID, 50)
|
|
if err != nil {
|
|
return []TimelineEvent{}, nil
|
|
}
|
|
var evts []TimelineEvent
|
|
for _, e := range entries {
|
|
if e.Action != "task_status_changed" {
|
|
continue
|
|
}
|
|
var details map[string]string
|
|
if jsonErr := json.Unmarshal([]byte(e.Details), &details); jsonErr != nil {
|
|
continue
|
|
}
|
|
ev := TimelineEvent{
|
|
From: details["old_status"],
|
|
To: details["new_status"],
|
|
At: e.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if ev.To == "" {
|
|
continue
|
|
}
|
|
evts = append(evts, ev)
|
|
}
|
|
if evts == nil {
|
|
evts = []TimelineEvent{}
|
|
}
|
|
return evts, nil
|
|
}
|