源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrContextRefAlreadyExists = errors.New("issue context reference already exists")
|
||||
ErrContextRefNotFound = errors.New("issue context reference not found")
|
||||
ErrInvalidArgument = errors.New("issue manager argument is invalid")
|
||||
ErrIssueAlreadyExists = errors.New("issue already exists")
|
||||
ErrIssueNotFound = errors.New("issue not found")
|
||||
ErrIssueBudgetSoftLimited = errors.New("issue budget is soft limited")
|
||||
ErrRunAlreadyExists = errors.New("issue task run already exists")
|
||||
ErrRunNotFound = errors.New("issue task run not found")
|
||||
ErrStoreNotConfigured = errors.New("issue manager store is not configured")
|
||||
ErrTaskAlreadyExists = errors.New("issue task already exists")
|
||||
ErrTaskNotFound = errors.New("issue task not found")
|
||||
ErrTaskDependenciesIncomplete = errors.New("issue task dependencies are incomplete")
|
||||
ErrTopicAlreadyExists = errors.New("issue topic already exists")
|
||||
ErrTopicNotEmpty = errors.New("issue topic is not empty")
|
||||
ErrTopicNotFound = errors.New("issue topic not found")
|
||||
ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||
ErrManagedIssueMutation = errors.New("tutti-owned issue is managed by its source conversation")
|
||||
)
|
||||
|
||||
// ManagedIssueMutationError rejects generic graph mutations for a Tutti-owned
|
||||
// Issue while preserving the exact source-conversation recovery target.
|
||||
type ManagedIssueMutationError struct {
|
||||
IssueID string
|
||||
SourceSessionID string
|
||||
}
|
||||
|
||||
func (err *ManagedIssueMutationError) Error() string {
|
||||
if err == nil {
|
||||
return ErrManagedIssueMutation.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: issue %s", ErrManagedIssueMutation, err.IssueID)
|
||||
}
|
||||
|
||||
func (*ManagedIssueMutationError) Unwrap() error {
|
||||
return ErrManagedIssueMutation
|
||||
}
|
||||
|
||||
func (err *ManagedIssueMutationError) ManagedIssueID() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.IssueID
|
||||
}
|
||||
|
||||
func (err *ManagedIssueMutationError) ManagedSourceSessionID() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.SourceSessionID
|
||||
}
|
||||
|
||||
// RejectManagedIssueMutation is the reusable graph-mutation admission rule.
|
||||
// Product orchestration uses its own source-scoped transaction and never
|
||||
// exposes a boolean bypass through generic Issue Manager inputs.
|
||||
func RejectManagedIssueMutation(issue Issue) error {
|
||||
if issue.PlanningSource != PlanningSourceTuttiModePlan {
|
||||
return nil
|
||||
}
|
||||
return &ManagedIssueMutationError{
|
||||
IssueID: strings.TrimSpace(issue.IssueID),
|
||||
SourceSessionID: strings.TrimSpace(issue.SourceSessionID),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module github.com/tutti-os/tutti/packages/workspace/issues
|
||||
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.5
|
||||
|
||||
require github.com/tutti-os/tutti/packages/agent/daemon v0.0.0
|
||||
|
||||
replace github.com/tutti-os/tutti/packages/agent/daemon => ../../agent/daemon
|
||||
@@ -0,0 +1,360 @@
|
||||
package workspaceissues
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusNotStarted Status = "not_started"
|
||||
StatusRunning Status = "running"
|
||||
StatusPendingAcceptance Status = "pending_acceptance"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCanceled Status = "canceled"
|
||||
)
|
||||
|
||||
type Priority string
|
||||
|
||||
const (
|
||||
PriorityHigh Priority = "high"
|
||||
PriorityMedium Priority = "medium"
|
||||
PriorityLow Priority = "low"
|
||||
)
|
||||
|
||||
type PlanningSource string
|
||||
|
||||
const (
|
||||
PlanningSourceManual PlanningSource = "manual"
|
||||
PlanningSourceTuttiModePlan PlanningSource = "tutti_mode_plan"
|
||||
PlanningSourceTraditionalPlan PlanningSource = "traditional_plan"
|
||||
)
|
||||
|
||||
type BudgetMode string
|
||||
|
||||
const (
|
||||
BudgetModeAuto BudgetMode = "auto"
|
||||
BudgetModeFixed BudgetMode = "fixed"
|
||||
)
|
||||
|
||||
type BudgetStatus string
|
||||
|
||||
const (
|
||||
BudgetStatusActive BudgetStatus = "active"
|
||||
BudgetStatusSoftLimited BudgetStatus = "soft_limited"
|
||||
)
|
||||
|
||||
type AcceptanceState string
|
||||
|
||||
const (
|
||||
AcceptanceAgentClaimed AcceptanceState = "agent_claimed"
|
||||
AcceptanceAutoChecked AcceptanceState = "auto_checked"
|
||||
AcceptanceUserAccepted AcceptanceState = "user_accepted"
|
||||
)
|
||||
|
||||
type ExecutionProfile struct {
|
||||
ReasoningIntensity int
|
||||
OrchestrationIntensity int
|
||||
}
|
||||
|
||||
type Budget struct {
|
||||
Mode BudgetMode
|
||||
TokenLimit int64
|
||||
ConsumedTokens int64
|
||||
QuotaWaterlinePercent float64
|
||||
RemainingQuotaPercent float64
|
||||
HasRemainingQuota bool
|
||||
Status BudgetStatus
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CacheReadTokens int64
|
||||
CacheWriteTokens int64
|
||||
}
|
||||
|
||||
func (u TokenUsage) Total() int64 {
|
||||
return u.InputTokens + u.OutputTokens + u.CacheReadTokens + u.CacheWriteTokens
|
||||
}
|
||||
|
||||
type Cost struct {
|
||||
Currency string
|
||||
EstimatedMicros int64
|
||||
ActualMicros int64
|
||||
}
|
||||
|
||||
type ContextRefParentKind string
|
||||
|
||||
const (
|
||||
ContextRefParentIssue ContextRefParentKind = "issue"
|
||||
ContextRefParentTask ContextRefParentKind = "task"
|
||||
)
|
||||
|
||||
const DefaultTopicID = "default"
|
||||
|
||||
type Topic struct {
|
||||
ID uint64
|
||||
TopicID string
|
||||
WorkspaceID string
|
||||
Title string
|
||||
Summary string
|
||||
IsDefault bool
|
||||
PinnedAtUnixMS int64
|
||||
LastActivityAtUnixMS int64
|
||||
CreatedAtUnixMS int64
|
||||
UpdatedAtUnixMS int64
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID uint64
|
||||
IssueID string
|
||||
TopicID string
|
||||
WorkspaceID string
|
||||
Title string
|
||||
Content string
|
||||
SearchText string
|
||||
Status Status
|
||||
TaskCount int
|
||||
NotStartedCount int
|
||||
RunningCount int
|
||||
PendingAcceptanceCount int
|
||||
CompletedCount int
|
||||
FailedCount int
|
||||
CanceledCount int
|
||||
CreatorUserID string
|
||||
CreatorDisplayName string
|
||||
CreatorAvatarURL string
|
||||
PlanningSource PlanningSource
|
||||
SourceSessionID string
|
||||
SequentialExecution bool
|
||||
ParallelExecution bool
|
||||
DispatchPaused bool
|
||||
ExecutionProfile ExecutionProfile
|
||||
Budget Budget
|
||||
CreatedAtUnixMS int64
|
||||
UpdatedAtUnixMS int64
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID uint64
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
Title string
|
||||
Content string
|
||||
SearchText string
|
||||
Status Status
|
||||
Priority Priority
|
||||
SortIndex int
|
||||
DueAtUnixMS int64
|
||||
CreatorUserID string
|
||||
CreatorDisplayName string
|
||||
CreatorAvatarURL string
|
||||
LatestRunID string
|
||||
AgentTargetID string
|
||||
ModelPlanID string
|
||||
Model string
|
||||
// PermissionModeID and ReasoningEffort are task-level launch overrides
|
||||
// recorded from the Tutti Mode plan review. Empty inherits the target's
|
||||
// composer default (permission mode) or the Issue-level reasoning
|
||||
// intensity compilation (reasoning effort).
|
||||
PermissionModeID string
|
||||
ReasoningEffort string
|
||||
ExecutionDirectory string
|
||||
DependencyTaskIDs []string
|
||||
// Parallelizable marks a task that may run alongside other ready tasks
|
||||
// even when the Issue executes sequentially. False keeps the sequential
|
||||
// default: the task waits for its predecessors in sort order.
|
||||
Parallelizable bool
|
||||
// AutoAccept bypasses the human acceptance gate: a successful run
|
||||
// completion is accepted automatically and dispatch advances. False keeps
|
||||
// the default pending-acceptance stop.
|
||||
AutoAccept bool
|
||||
AcceptanceState AcceptanceState
|
||||
AcceptanceSummary string
|
||||
// SupersededAtUnixMS logically removes an obsolete task from the active
|
||||
// graph without deleting its Runs, outputs, or audit history.
|
||||
SupersededAtUnixMS int64
|
||||
SupersededByTaskID string
|
||||
CreatedAtUnixMS int64
|
||||
UpdatedAtUnixMS int64
|
||||
}
|
||||
|
||||
func (task Task) IsSuperseded() bool {
|
||||
return task.SupersededAtUnixMS > 0
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
ID uint64
|
||||
RunID string
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
RequesterUserID string
|
||||
AgentUserID string
|
||||
AgentTargetID string
|
||||
AgentSessionID string
|
||||
AgentProvider string
|
||||
ModelPlanID string
|
||||
Model string
|
||||
ReasoningIntensity int
|
||||
Status Status
|
||||
Usage TokenUsage
|
||||
Summary string
|
||||
ErrorMessage string
|
||||
OutputDir string
|
||||
ExecutionDirectory string
|
||||
CreatedAtUnixMS int64
|
||||
StartedAtUnixMS int64
|
||||
CompletedAtUnixMS int64
|
||||
UpdatedAtUnixMS int64
|
||||
}
|
||||
|
||||
// PreparedRunLaunch is the transport-neutral durable identity needed to
|
||||
// recover an external Run delivery without creating a second Agent turn.
|
||||
type PreparedRunLaunch struct {
|
||||
Run Run
|
||||
ClientSubmitID string
|
||||
// OpaquePayload is owned and decoded by the host adapter that admitted the
|
||||
// external delivery. The reusable Issue core never interprets host paths or
|
||||
// provider-preparation details stored in it.
|
||||
OpaquePayload string
|
||||
}
|
||||
|
||||
type RunLaunchSettlement struct {
|
||||
WorkspaceID string
|
||||
IssueID string
|
||||
RunID string
|
||||
LeaseOwner string
|
||||
Status Status
|
||||
ErrorMessage string
|
||||
NowUnixMS int64
|
||||
}
|
||||
|
||||
type RunOutput struct {
|
||||
ID uint64
|
||||
OutputID string
|
||||
RunID string
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
Path string
|
||||
DisplayName string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
CreatedAtUnixMS int64
|
||||
}
|
||||
|
||||
// RunOutputSearchParams scopes a workspace-wide search over produced output files.
|
||||
// Query matches output file display names. IssueID and TopicID are optional
|
||||
// scopes; IssueID takes precedence when both are set.
|
||||
type RunOutputSearchParams struct {
|
||||
WorkspaceID string
|
||||
Query string
|
||||
// Filters 为已选「文件类型筛选分类」id(全局统一口径)。筛选与搜索是同一能力:
|
||||
// Query 可空、Filters 非空时即按类型查。空 = 不按类型过滤。
|
||||
Filters []string
|
||||
IssueID string
|
||||
TopicID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RunOutputSearchHit is one matched output file annotated with its owning issue
|
||||
// title, used as the search-result subtitle.
|
||||
type RunOutputSearchHit struct {
|
||||
Output RunOutput
|
||||
IssueTitle string
|
||||
}
|
||||
|
||||
type ContextRef struct {
|
||||
ID uint64
|
||||
ContextRefID string
|
||||
WorkspaceID string
|
||||
IssueID string
|
||||
TaskID string
|
||||
ParentKind ContextRefParentKind
|
||||
RefType string
|
||||
Path string
|
||||
DisplayName string
|
||||
CreatedAtUnixMS int64
|
||||
}
|
||||
|
||||
type StatusCounts struct {
|
||||
All int
|
||||
NotStarted int
|
||||
Running int
|
||||
PendingAcceptance int
|
||||
Completed int
|
||||
Failed int
|
||||
Canceled int
|
||||
}
|
||||
|
||||
type IssueListCursor struct {
|
||||
UpdatedAtUnixMS int64
|
||||
ID uint64
|
||||
}
|
||||
|
||||
type IssueListFilter struct {
|
||||
WorkspaceID string
|
||||
TopicID string
|
||||
PageSize int
|
||||
Cursor *IssueListCursor
|
||||
StatusFilter Status
|
||||
SearchQuery string
|
||||
ReturnAll bool
|
||||
}
|
||||
|
||||
type IssueList struct {
|
||||
Items []Issue
|
||||
NextCursor *IssueListCursor
|
||||
NextPageToken string
|
||||
TotalCount int
|
||||
StatusCounts StatusCounts
|
||||
}
|
||||
|
||||
type TopicList struct {
|
||||
Items []Topic
|
||||
}
|
||||
|
||||
type TaskListCursor struct {
|
||||
SortIndex int
|
||||
ID uint64
|
||||
}
|
||||
|
||||
type TaskListFilter struct {
|
||||
WorkspaceID string
|
||||
IssueID string
|
||||
PageSize int
|
||||
Cursor *TaskListCursor
|
||||
StatusFilter Status
|
||||
SearchQuery string
|
||||
ReturnAll bool
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
Items []Task
|
||||
NextCursor *TaskListCursor
|
||||
NextPageToken string
|
||||
TotalCount int
|
||||
StatusCounts StatusCounts
|
||||
}
|
||||
|
||||
type IssueDetail struct {
|
||||
Issue Issue
|
||||
Tasks []Task
|
||||
ContextRefs []ContextRef
|
||||
LatestRun *Run
|
||||
RecentRuns []Run
|
||||
LatestOutputs []RunOutput
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Task Task
|
||||
ContextRefs []ContextRef
|
||||
LatestRun *Run
|
||||
RecentRuns []Run
|
||||
LatestOutputs []RunOutput
|
||||
}
|
||||
|
||||
type RunDetail struct {
|
||||
Run Run
|
||||
Outputs []RunOutput
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxWorkspaceParallelRuns is the shared admission ceiling for Issue task
|
||||
// Runs, regardless of whether the generic dispatcher or Tutti execution
|
||||
// admits them.
|
||||
const MaxWorkspaceParallelRuns = 4
|
||||
|
||||
const (
|
||||
DefaultReasoningIntensity = 50
|
||||
DefaultOrchestrationIntensity = 50
|
||||
DefaultQuotaWaterlinePercent = 10
|
||||
minimumAutoTokenBudget = int64(32_000)
|
||||
maximumAutoTokenBudget = int64(2_000_000)
|
||||
)
|
||||
|
||||
func NormalizeStatus(raw string) (Status, bool) {
|
||||
switch Status(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case StatusNotStarted, StatusRunning, StatusPendingAcceptance, StatusCompleted, StatusFailed, StatusCanceled:
|
||||
return Status(strings.ToLower(strings.TrimSpace(raw))), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeRunCompletionStatus(raw string) (Status, bool) {
|
||||
switch Status(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case StatusCompleted, StatusFailed, StatusCanceled:
|
||||
return Status(strings.ToLower(strings.TrimSpace(raw))), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePriority(raw string) Priority {
|
||||
switch Priority(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case PriorityHigh, PriorityMedium, PriorityLow:
|
||||
return Priority(strings.ToLower(strings.TrimSpace(raw)))
|
||||
default:
|
||||
return PriorityMedium
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePlanningSource(raw string) (PlanningSource, bool) {
|
||||
switch PlanningSource(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case PlanningSourceManual, PlanningSourceTuttiModePlan, PlanningSourceTraditionalPlan:
|
||||
return PlanningSource(strings.ToLower(strings.TrimSpace(raw))), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultExecutionProfile() ExecutionProfile {
|
||||
return ExecutionProfile{
|
||||
ReasoningIntensity: DefaultReasoningIntensity,
|
||||
OrchestrationIntensity: DefaultOrchestrationIntensity,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeExecutionProfile(profile ExecutionProfile) (ExecutionProfile, bool) {
|
||||
if profile.ReasoningIntensity < 0 || profile.ReasoningIntensity > 100 ||
|
||||
profile.OrchestrationIntensity < 0 || profile.OrchestrationIntensity > 100 {
|
||||
return ExecutionProfile{}, false
|
||||
}
|
||||
return profile, true
|
||||
}
|
||||
|
||||
func DefaultBudget() Budget {
|
||||
return Budget{
|
||||
Mode: BudgetModeAuto,
|
||||
QuotaWaterlinePercent: DefaultQuotaWaterlinePercent,
|
||||
Status: BudgetStatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeBudget(value Budget) (Budget, bool) {
|
||||
value.Mode = BudgetMode(strings.ToLower(strings.TrimSpace(string(value.Mode))))
|
||||
if value.Mode == "" {
|
||||
value.Mode = BudgetModeAuto
|
||||
}
|
||||
if value.Status == "" {
|
||||
value.Status = BudgetStatusActive
|
||||
} else {
|
||||
value.Status = BudgetStatus(strings.ToLower(strings.TrimSpace(string(value.Status))))
|
||||
}
|
||||
if value.TokenLimit < 0 || value.ConsumedTokens < 0 || !finitePercentage(value.QuotaWaterlinePercent) ||
|
||||
!finiteNumber(value.RemainingQuotaPercent) ||
|
||||
value.HasRemainingQuota && !finitePercentage(value.RemainingQuotaPercent) {
|
||||
return Budget{}, false
|
||||
}
|
||||
switch value.Mode {
|
||||
case BudgetModeAuto:
|
||||
// The compiler owns the effective limit in auto mode.
|
||||
value.TokenLimit = maxInt64(value.TokenLimit, 0)
|
||||
case BudgetModeFixed:
|
||||
if value.TokenLimit <= 0 {
|
||||
return Budget{}, false
|
||||
}
|
||||
default:
|
||||
return Budget{}, false
|
||||
}
|
||||
switch value.Status {
|
||||
case BudgetStatusActive, BudgetStatusSoftLimited:
|
||||
default:
|
||||
return Budget{}, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func finitePercentage(value float64) bool {
|
||||
return finiteNumber(value) && value >= 0 && value <= 100
|
||||
}
|
||||
|
||||
func finiteNumber(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func CompileAutoTokenBudget(taskCount int, profile ExecutionProfile) int64 {
|
||||
if taskCount < 1 {
|
||||
taskCount = 1
|
||||
}
|
||||
compiled := int64(16_000) + int64(taskCount)*CompileEstimatedRunTokenBudget(profile)
|
||||
if compiled < minimumAutoTokenBudget {
|
||||
return minimumAutoTokenBudget
|
||||
}
|
||||
if compiled > maximumAutoTokenBudget {
|
||||
return maximumAutoTokenBudget
|
||||
}
|
||||
return compiled
|
||||
}
|
||||
|
||||
// CompileEstimatedRunTokenBudget returns the conservative allowance reserved
|
||||
// before one automatic Issue task is dispatched. Keeping this compiler aligned
|
||||
// with CompileAutoTokenBudget lets lowering the Issue intensities make a
|
||||
// previously blocked remaining task fit without silently increasing a fixed
|
||||
// token limit.
|
||||
func CompileEstimatedRunTokenBudget(profile ExecutionProfile) int64 {
|
||||
return 24_000 + int64(profile.ReasoningIntensity)*320 + int64(profile.OrchestrationIntensity)*160
|
||||
}
|
||||
|
||||
// IssueBudgetAllowsNextAutomaticRun applies the pre-dispatch soft gate. It
|
||||
// reserves an estimated allowance instead of waiting until actual usage has
|
||||
// already crossed the limit, which makes the documented lower-intensity
|
||||
// recovery path meaningful.
|
||||
func IssueBudgetAllowsNextAutomaticRun(issue Issue) bool {
|
||||
if issue.Budget.Status != BudgetStatusActive {
|
||||
return false
|
||||
}
|
||||
if issue.Budget.HasRemainingQuota && issue.Budget.RemainingQuotaPercent <= issue.Budget.QuotaWaterlinePercent {
|
||||
return false
|
||||
}
|
||||
if issue.Budget.TokenLimit <= 0 {
|
||||
return true
|
||||
}
|
||||
remaining := issue.Budget.TokenLimit - issue.Budget.ConsumedTokens
|
||||
return remaining >= CompileEstimatedRunTokenBudget(issue.ExecutionProfile)
|
||||
}
|
||||
|
||||
// IssueAutomaticBudgetSlots reserves one estimated allowance for every
|
||||
// already-active Run before admitting more work for the same Issue.
|
||||
func IssueAutomaticBudgetSlots(issue Issue, activeRunCount int) int {
|
||||
if !IssueBudgetAllowsNextAutomaticRun(issue) {
|
||||
return 0
|
||||
}
|
||||
if issue.Budget.TokenLimit <= 0 {
|
||||
return MaxWorkspaceParallelRuns
|
||||
}
|
||||
allowance := CompileEstimatedRunTokenBudget(issue.ExecutionProfile)
|
||||
remaining := issue.Budget.TokenLimit - issue.Budget.ConsumedTokens
|
||||
if allowance <= 0 || remaining < allowance {
|
||||
return 0
|
||||
}
|
||||
slots := int(remaining/allowance) - activeRunCount
|
||||
if slots < 0 {
|
||||
return 0
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// IssueAutomaticRunAdmissionSlots is the shared concurrency and budget policy
|
||||
// for every automatic Issue Run admission path. Persistence adapters supply
|
||||
// authoritative counts, then compare-and-set the selected Runs atomically.
|
||||
func IssueAutomaticRunAdmissionSlots(issue Issue, workspaceActiveRunCount int, issueActiveRunCount int) int {
|
||||
workspaceSlots := MaxWorkspaceParallelRuns - workspaceActiveRunCount
|
||||
if workspaceSlots < 0 {
|
||||
workspaceSlots = 0
|
||||
}
|
||||
return min(workspaceSlots, IssueAutomaticBudgetSlots(issue, issueActiveRunCount))
|
||||
}
|
||||
|
||||
type TaskRunBlocker string
|
||||
|
||||
const (
|
||||
TaskRunBlockerSuperseded TaskRunBlocker = "task_superseded"
|
||||
TaskRunBlockerNotStarted TaskRunBlocker = "task_not_started"
|
||||
TaskRunBlockerMissingAgentTarget TaskRunBlocker = "missing_agent_target"
|
||||
TaskRunBlockerDependencyUnsatisfied TaskRunBlocker = "dependency_unsatisfied"
|
||||
)
|
||||
|
||||
// IssueTaskRunBlocker is the shared task/dependency predicate used by generic
|
||||
// dispatch, source-Agent exact-set scheduling, and execution diagnostics.
|
||||
func IssueTaskRunBlocker(task Task, tasksByID map[string]Task) TaskRunBlocker {
|
||||
if task.IsSuperseded() {
|
||||
return TaskRunBlockerSuperseded
|
||||
}
|
||||
if task.Status != StatusNotStarted {
|
||||
return TaskRunBlockerNotStarted
|
||||
}
|
||||
if strings.TrimSpace(task.AgentTargetID) == "" {
|
||||
return TaskRunBlockerMissingAgentTarget
|
||||
}
|
||||
for _, dependencyID := range task.DependencyTaskIDs {
|
||||
dependency, ok := tasksByID[dependencyID]
|
||||
if !ok || dependency.IsSuperseded() ||
|
||||
dependency.Status != StatusCompleted ||
|
||||
dependency.AcceptanceState != AcceptanceUserAccepted {
|
||||
return TaskRunBlockerDependencyUnsatisfied
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IssueTaskEligibleForRun preserves the boolean admission interface for
|
||||
// existing dispatch callers while diagnostics retain the exact blocker.
|
||||
func IssueTaskEligibleForRun(task Task, tasksByID map[string]Task) bool {
|
||||
return IssueTaskRunBlocker(task, tasksByID) == ""
|
||||
}
|
||||
|
||||
// ProjectReviewedSettlementTaskForRun applies the acceptance fact that an
|
||||
// exact-set schedule commits when it advances from a task_settled checkpoint.
|
||||
// The returned copy is for admission/readiness evaluation only.
|
||||
func ProjectReviewedSettlementTaskForRun(task Task) Task {
|
||||
if task.Status == StatusPendingAcceptance {
|
||||
task.Status = StatusCompleted
|
||||
task.AcceptanceState = AcceptanceUserAccepted
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func IssueRunClientSubmitID(runID string) string {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return ""
|
||||
}
|
||||
return "issue-run:" + runID
|
||||
}
|
||||
|
||||
// CompileAutoTokenBudgetWithHistory blends the deterministic scale/intensity
|
||||
// compiler with the observed total usage of comparable completed tasks. The
|
||||
// deterministic result remains the fallback and contributes half the result,
|
||||
// so one unusual historical run cannot wholly redefine a new Issue budget.
|
||||
func CompileAutoTokenBudgetWithHistory(taskCount int, profile ExecutionProfile, historicalTotalTokens int64) int64 {
|
||||
compiled := CompileAutoTokenBudget(taskCount, profile)
|
||||
if historicalTotalTokens <= 0 {
|
||||
return compiled
|
||||
}
|
||||
historicalWithHeadroom := historicalTotalTokens + historicalTotalTokens/4
|
||||
if historicalWithHeadroom < minimumAutoTokenBudget {
|
||||
historicalWithHeadroom = minimumAutoTokenBudget
|
||||
}
|
||||
if historicalWithHeadroom > maximumAutoTokenBudget {
|
||||
historicalWithHeadroom = maximumAutoTokenBudget
|
||||
}
|
||||
blended := compiled/2 + historicalWithHeadroom/2
|
||||
if blended < minimumAutoTokenBudget {
|
||||
return minimumAutoTokenBudget
|
||||
}
|
||||
if blended > maximumAutoTokenBudget {
|
||||
return maximumAutoTokenBudget
|
||||
}
|
||||
return blended
|
||||
}
|
||||
|
||||
func NormalizeAcceptanceState(raw string) (AcceptanceState, bool) {
|
||||
switch AcceptanceState(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case AcceptanceAgentClaimed, AcceptanceAutoChecked, AcceptanceUserAccepted:
|
||||
return AcceptanceState(strings.ToLower(strings.TrimSpace(raw))), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func CanTransitionAcceptance(current AcceptanceState, next AcceptanceState) bool {
|
||||
if current == next {
|
||||
return true
|
||||
}
|
||||
switch current {
|
||||
case AcceptanceAgentClaimed:
|
||||
return next == AcceptanceAutoChecked || next == AcceptanceUserAccepted
|
||||
case AcceptanceAutoChecked:
|
||||
return next == AcceptanceUserAccepted
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeDependencyTaskIDs(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ValidateTaskDependencyGraph(tasks []Task) bool {
|
||||
byID := make(map[string]Task, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task.TaskID == "" {
|
||||
return false
|
||||
}
|
||||
byID[task.TaskID] = task
|
||||
}
|
||||
visiting := make(map[string]bool, len(tasks))
|
||||
visited := make(map[string]bool, len(tasks))
|
||||
var visit func(string) bool
|
||||
visit = func(taskID string) bool {
|
||||
if visiting[taskID] {
|
||||
return false
|
||||
}
|
||||
if visited[taskID] {
|
||||
return true
|
||||
}
|
||||
task, exists := byID[taskID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
visiting[taskID] = true
|
||||
for _, dependencyID := range task.DependencyTaskIDs {
|
||||
if dependencyID == taskID || !visit(dependencyID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
visiting[taskID] = false
|
||||
visited[taskID] = true
|
||||
return true
|
||||
}
|
||||
ids := make([]string, 0, len(byID))
|
||||
for taskID := range byID {
|
||||
ids = append(ids, taskID)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
for _, taskID := range ids {
|
||||
if !visit(taskID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TaskStatusForCompletedRun(status Status) Status {
|
||||
if status == StatusCompleted {
|
||||
return StatusPendingAcceptance
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func ProjectIssueStatus(counts StatusCounts) Status {
|
||||
switch {
|
||||
case counts.Running > 0:
|
||||
return StatusRunning
|
||||
case counts.Failed > 0:
|
||||
return StatusFailed
|
||||
case counts.All == 0 || counts.NotStarted == counts.All:
|
||||
return StatusNotStarted
|
||||
case counts.All > 0 && counts.Completed == counts.All:
|
||||
return StatusCompleted
|
||||
case counts.All > 0 && counts.PendingAcceptance > 0 && counts.NotStarted == 0 && counts.Running == 0 && counts.Failed == 0:
|
||||
return StatusPendingAcceptance
|
||||
case counts.All > 0 && counts.Canceled == counts.All:
|
||||
return StatusCanceled
|
||||
default:
|
||||
return StatusRunning
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeContextRefParentKind(raw string) (ContextRefParentKind, bool) {
|
||||
switch ContextRefParentKind(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case ContextRefParentIssue, ContextRefParentTask:
|
||||
return ContextRefParentKind(strings.ToLower(strings.TrimSpace(raw))), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func TrimSearchText(content string) string {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NormalizeTokenUsage(value TokenUsage) (TokenUsage, bool) {
|
||||
if value.InputTokens < 0 || value.OutputTokens < 0 || value.CacheReadTokens < 0 || value.CacheWriteTokens < 0 {
|
||||
return TokenUsage{}, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func maxInt(left int, right int) int {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeBudgetRejectsNonFinitePercentages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
budget Budget
|
||||
}{
|
||||
{name: "quota nan", budget: Budget{Mode: BudgetModeAuto, QuotaWaterlinePercent: math.NaN()}},
|
||||
{name: "quota positive infinity", budget: Budget{Mode: BudgetModeAuto, QuotaWaterlinePercent: math.Inf(1)}},
|
||||
{name: "quota negative infinity", budget: Budget{Mode: BudgetModeAuto, QuotaWaterlinePercent: math.Inf(-1)}},
|
||||
{name: "remaining nan", budget: Budget{Mode: BudgetModeAuto, HasRemainingQuota: true, RemainingQuotaPercent: math.NaN()}},
|
||||
{name: "remaining positive infinity", budget: Budget{Mode: BudgetModeAuto, HasRemainingQuota: true, RemainingQuotaPercent: math.Inf(1)}},
|
||||
{name: "remaining negative infinity", budget: Budget{Mode: BudgetModeAuto, HasRemainingQuota: true, RemainingQuotaPercent: math.Inf(-1)}},
|
||||
{name: "unobserved remaining nan", budget: Budget{Mode: BudgetModeAuto, RemainingQuotaPercent: math.NaN()}},
|
||||
{name: "unobserved remaining infinity", budget: Budget{Mode: BudgetModeAuto, RemainingQuotaPercent: math.Inf(1)}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if normalized, ok := NormalizeBudget(test.budget); ok {
|
||||
t.Fatalf("NormalizeBudget(%s) = %#v, want rejection", test.name, normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAutomaticRunAdmissionSlotsCombinesWorkspaceAndIssueLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
issue := Issue{
|
||||
Budget: Budget{
|
||||
Status: BudgetStatusActive,
|
||||
TokenLimit: CompileEstimatedRunTokenBudget(ExecutionProfile{}) * 4,
|
||||
},
|
||||
}
|
||||
if got := IssueAutomaticRunAdmissionSlots(issue, 3, 1); got != 1 {
|
||||
t.Fatalf("IssueAutomaticRunAdmissionSlots() = %d, want workspace-limited 1", got)
|
||||
}
|
||||
if got := IssueAutomaticRunAdmissionSlots(issue, 2, 2); got != 2 {
|
||||
t.Fatalf("IssueAutomaticRunAdmissionSlots() = %d, want budget-limited 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTaskEligibleForRunRequiresAcceptedDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dependency := Task{
|
||||
TaskID: "dependency",
|
||||
Status: StatusCompleted,
|
||||
AcceptanceState: AcceptanceUserAccepted,
|
||||
}
|
||||
task := Task{
|
||||
TaskID: "task",
|
||||
Status: StatusNotStarted,
|
||||
AgentTargetID: "local:codex",
|
||||
DependencyTaskIDs: []string{dependency.TaskID},
|
||||
}
|
||||
byID := map[string]Task{dependency.TaskID: dependency, task.TaskID: task}
|
||||
if !IssueTaskEligibleForRun(task, byID) {
|
||||
t.Fatal("IssueTaskEligibleForRun() = false, want accepted dependency eligible")
|
||||
}
|
||||
dependency.AcceptanceState = AcceptanceAgentClaimed
|
||||
byID[dependency.TaskID] = dependency
|
||||
if IssueTaskEligibleForRun(task, byID) {
|
||||
t.Fatal("IssueTaskEligibleForRun() = true, want unaccepted dependency rejected")
|
||||
}
|
||||
delete(byID, dependency.TaskID)
|
||||
if IssueTaskEligibleForRun(task, byID) {
|
||||
t.Fatal("IssueTaskEligibleForRun() = true, want missing dependency rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EncodeIssueListCursorToken(cursor *IssueListCursor) string {
|
||||
if cursor == nil || cursor.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return encodePageToken(cursor)
|
||||
}
|
||||
|
||||
func DecodeIssueListCursorToken(token string) (*IssueListCursor, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var cursor IssueListCursor
|
||||
if err := decodePageToken(token, &cursor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cursor.ID == 0 {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
return &cursor, nil
|
||||
}
|
||||
|
||||
func EncodeTaskListCursorToken(cursor *TaskListCursor) string {
|
||||
if cursor == nil || cursor.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return encodePageToken(cursor)
|
||||
}
|
||||
|
||||
func DecodeTaskListCursorToken(token string) (*TaskListCursor, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var cursor TaskListCursor
|
||||
if err := decodePageToken(token, &cursor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cursor.ID == 0 {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
return &cursor, nil
|
||||
}
|
||||
|
||||
func encodePageToken(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func decodePageToken(token string, target any) error {
|
||||
data, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return ErrInvalidArgument
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
|
||||
)
|
||||
|
||||
type IDKind string
|
||||
|
||||
const (
|
||||
IDKindTopic IDKind = "topic"
|
||||
IDKindIssue IDKind = "issue"
|
||||
IDKindTask IDKind = "task"
|
||||
IDKindRun IDKind = "run"
|
||||
IDKindRunOutput IDKind = "run-output"
|
||||
IDKindContextRef IDKind = "context-ref"
|
||||
)
|
||||
|
||||
type IDGenerator func(IDKind) string
|
||||
|
||||
type Clock func() time.Time
|
||||
|
||||
type Service struct {
|
||||
Clock Clock
|
||||
IDGenerator IDGenerator
|
||||
Store Store
|
||||
}
|
||||
|
||||
type CreateIssueInput struct {
|
||||
IssueID string
|
||||
TopicID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
Content string
|
||||
PlanningSource string
|
||||
SourceSessionID string
|
||||
SequentialExecution bool
|
||||
ParallelExecution bool
|
||||
ExecutionProfile ExecutionProfile
|
||||
HasExecutionProfile bool
|
||||
Budget Budget
|
||||
HasBudget bool
|
||||
// AutoTokenBudgetHistoryHint is the summed observed usage of comparable
|
||||
// completed task runs. It is ignored for fixed budgets.
|
||||
AutoTokenBudgetHistoryHint int64
|
||||
}
|
||||
|
||||
type CreateIssueWithTasksInput struct {
|
||||
Issue CreateIssueInput
|
||||
Tasks []CreateTaskItemInput
|
||||
}
|
||||
|
||||
type CreateIssueWithContextRefsInput struct {
|
||||
Issue CreateIssueInput
|
||||
Refs []AddContextRefInput
|
||||
}
|
||||
|
||||
type CreateTopicInput struct {
|
||||
TopicID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type UpdateTopicInput struct {
|
||||
TopicID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
HasTitle bool
|
||||
Summary string
|
||||
HasSummary bool
|
||||
Pinned bool
|
||||
HasPinned bool
|
||||
}
|
||||
|
||||
type UpdateIssueInput struct {
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
HasTitle bool
|
||||
Content string
|
||||
HasContent bool
|
||||
Status string
|
||||
HasStatus bool
|
||||
DispatchPaused bool
|
||||
HasDispatchPaused bool
|
||||
ExecutionProfile ExecutionProfile
|
||||
HasExecutionProfile bool
|
||||
Budget Budget
|
||||
HasBudget bool
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
Content string
|
||||
Priority string
|
||||
DueAtUnixMS int64
|
||||
AgentTargetID string
|
||||
ModelPlanID string
|
||||
Model string
|
||||
PermissionModeID string
|
||||
ReasoningEffort string
|
||||
ExecutionDirectory string
|
||||
DependencyTaskIDs []string
|
||||
Parallelizable bool
|
||||
AutoAccept bool
|
||||
}
|
||||
|
||||
type CreateTaskItemInput struct {
|
||||
TaskID string
|
||||
Title string
|
||||
Content string
|
||||
Priority string
|
||||
DueAtUnixMS int64
|
||||
AgentTargetID string
|
||||
ModelPlanID string
|
||||
Model string
|
||||
PermissionModeID string
|
||||
ReasoningEffort string
|
||||
ExecutionDirectory string
|
||||
DependencyTaskIDs []string
|
||||
Parallelizable bool
|
||||
AutoAccept bool
|
||||
}
|
||||
|
||||
type CreateTasksInput struct {
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Tasks []CreateTaskItemInput
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Title string
|
||||
HasTitle bool
|
||||
Content string
|
||||
HasContent bool
|
||||
Status string
|
||||
HasStatus bool
|
||||
Priority string
|
||||
HasPriority bool
|
||||
DueAtUnixMS int64
|
||||
HasDueAt bool
|
||||
SortIndex int
|
||||
HasSortIndex bool
|
||||
AgentTargetID string
|
||||
HasAgentTargetID bool
|
||||
ModelPlanID string
|
||||
HasModelPlanID bool
|
||||
Model string
|
||||
HasModel bool
|
||||
ExecutionDirectory string
|
||||
HasExecutionDirectory bool
|
||||
DependencyTaskIDs []string
|
||||
HasDependencyTaskIDs bool
|
||||
Parallelizable bool
|
||||
HasParallelizable bool
|
||||
AutoAccept bool
|
||||
HasAutoAccept bool
|
||||
AcceptanceState string
|
||||
HasAcceptanceState bool
|
||||
AcceptanceSummary string
|
||||
HasAcceptanceSummary bool
|
||||
}
|
||||
|
||||
type CreateRunInput struct {
|
||||
RunID string
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
AgentTargetID string
|
||||
AgentProvider string
|
||||
AgentUserID string
|
||||
AgentSessionID string
|
||||
ModelPlanID string
|
||||
Model string
|
||||
ExecutionDirectory string
|
||||
}
|
||||
|
||||
type PreparedRun struct {
|
||||
Run Run
|
||||
Task Task
|
||||
TaskIsNew bool
|
||||
Issue Issue
|
||||
}
|
||||
|
||||
type CompleteRunOutputInput struct {
|
||||
OutputID string
|
||||
Path string
|
||||
DisplayName string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
type CompleteRunInput struct {
|
||||
RunID string
|
||||
TaskID string
|
||||
IssueID string
|
||||
WorkspaceID string
|
||||
ActorUserID string
|
||||
Status string
|
||||
Summary string
|
||||
ErrorMessage string
|
||||
Outputs []CompleteRunOutputInput
|
||||
Usage TokenUsage
|
||||
RemainingQuotaPercent float64
|
||||
HasRemainingQuotaPercent bool
|
||||
}
|
||||
|
||||
type AddContextRefsInput struct {
|
||||
WorkspaceID string
|
||||
IssueID string
|
||||
TaskID string
|
||||
ParentKind string
|
||||
Refs []AddContextRefInput
|
||||
}
|
||||
|
||||
type RemoveContextRefInput struct {
|
||||
WorkspaceID string
|
||||
IssueID string
|
||||
TaskID string
|
||||
ParentKind string
|
||||
ContextRefID string
|
||||
}
|
||||
|
||||
type AddContextRefInput struct {
|
||||
ContextRefID string
|
||||
RefType string
|
||||
Path string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
func (s Service) ListRuns(ctx context.Context, workspaceID string, issueID string, taskID string) ([]Run, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
if workspaceID == "" || issueID == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
if err := ensureRunParentExists(ctx, store, workspaceID, issueID, taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.ListRuns(ctx, workspaceID, issueID, taskID)
|
||||
}
|
||||
|
||||
func (s Service) ListRunningRuns(ctx context.Context, workspaceID string, limit int) ([]Run, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
if workspaceID == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
return store.ListRunningRuns(ctx, workspaceID, limit)
|
||||
}
|
||||
|
||||
func (s Service) GetRunDetail(ctx context.Context, workspaceID string, issueID string, taskID string, runID string) (RunDetail, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
runID = strings.TrimSpace(runID)
|
||||
if workspaceID == "" || issueID == "" || runID == "" {
|
||||
return RunDetail{}, ErrInvalidArgument
|
||||
}
|
||||
if err := ensureRunParentExists(ctx, store, workspaceID, issueID, taskID); err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
if taskID == "" {
|
||||
resolvedTaskID, err := findIssueRunTaskID(ctx, store, workspaceID, issueID, runID)
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
taskID = resolvedTaskID
|
||||
}
|
||||
run, err := store.GetRun(ctx, workspaceID, issueID, taskID, runID)
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
outputs, err := store.ListRunOutputs(ctx, workspaceID, issueID, taskID, runID)
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
return RunDetail{
|
||||
Run: run,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) CreateRun(ctx context.Context, input CreateRunInput) (Run, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
prepared, err := s.PrepareRun(ctx, input)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if prepared.TaskIsNew {
|
||||
createdTasks, err := store.AppendTasks(ctx, []Task{prepared.Task})
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if len(createdTasks) != 1 {
|
||||
return Run{}, ErrInvalidArgument
|
||||
}
|
||||
prepared.Task = createdTasks[0]
|
||||
}
|
||||
created, err := store.CreateRun(ctx, prepared.Run)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
task := prepared.Task
|
||||
task.Status = StatusRunning
|
||||
// A fresh run is a new Agent completion claim; any earlier acceptance
|
||||
// verdict or review evidence belongs to the previous run.
|
||||
task.AcceptanceState = AcceptanceAgentClaimed
|
||||
task.AcceptanceSummary = ""
|
||||
task.LatestRunID = created.RunID
|
||||
task.UpdatedAtUnixMS = prepared.Run.UpdatedAtUnixMS
|
||||
if _, err := store.UpdateTask(ctx, task); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if _, err := store.RecalculateIssueProjection(ctx, prepared.Run.WorkspaceID, prepared.Run.IssueID); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, prepared.Run.WorkspaceID, prepared.Issue.TopicID, prepared.Run.UpdatedAtUnixMS); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// PrepareRun validates and normalizes a Run without claiming it durably. A
|
||||
// product adapter uses this before atomically committing the Run and a durable
|
||||
// external-launch intent in one transaction.
|
||||
func (s Service) PrepareRun(ctx context.Context, input CreateRunInput) (PreparedRun, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return PreparedRun{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
taskID := strings.TrimSpace(input.TaskID)
|
||||
actorUserID := strings.TrimSpace(input.ActorUserID)
|
||||
agentTargetID := strings.TrimSpace(input.AgentTargetID)
|
||||
agentProvider := strings.ToLower(strings.TrimSpace(input.AgentProvider))
|
||||
if workspaceID == "" || issueID == "" || actorUserID == "" {
|
||||
return PreparedRun{}, ErrInvalidArgument
|
||||
}
|
||||
issue, task, err := getRunParent(ctx, store, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return PreparedRun{}, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return PreparedRun{}, err
|
||||
}
|
||||
taskIsNew := false
|
||||
if task == nil {
|
||||
preparedTask, preparedTaskIsNew, err := s.prepareIssueRunTask(ctx, store, issue, actorUserID)
|
||||
if err != nil {
|
||||
return PreparedRun{}, err
|
||||
}
|
||||
task = &preparedTask
|
||||
taskIsNew = preparedTaskIsNew
|
||||
taskID = task.TaskID
|
||||
}
|
||||
if !IssueBudgetAllowsNextAutomaticRun(issue) {
|
||||
return PreparedRun{}, ErrIssueBudgetSoftLimited
|
||||
}
|
||||
if !taskDependenciesAccepted(ctx, store, workspaceID, issueID, *task) {
|
||||
return PreparedRun{}, ErrTaskDependenciesIncomplete
|
||||
}
|
||||
if agentTargetID == "" {
|
||||
agentTargetID = strings.TrimSpace(task.AgentTargetID)
|
||||
}
|
||||
if agentTargetID == "" {
|
||||
agentTargetID = legacyAgentTargetIDForProvider(agentProvider)
|
||||
}
|
||||
if agentProvider == "" {
|
||||
agentProvider = agentProviderForAgentTargetID(agentTargetID)
|
||||
}
|
||||
if agentTargetID == "" {
|
||||
return PreparedRun{}, ErrInvalidArgument
|
||||
}
|
||||
modelPlanID := firstNonEmpty(input.ModelPlanID, task.ModelPlanID)
|
||||
model := firstNonEmpty(input.Model, task.Model)
|
||||
reasoningIntensity := issue.ExecutionProfile.ReasoningIntensity
|
||||
if reasoningIntensity < 0 || reasoningIntensity > 100 {
|
||||
return PreparedRun{}, ErrInvalidArgument
|
||||
}
|
||||
executionDirectory := firstNonEmpty(input.ExecutionDirectory, task.ExecutionDirectory)
|
||||
now := s.nowUnixMS()
|
||||
resolvedRunID := s.resolveID(IDKindRun, input.RunID)
|
||||
run := Run{
|
||||
RunID: resolvedRunID,
|
||||
TaskID: taskID,
|
||||
IssueID: issue.IssueID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
RequesterUserID: actorUserID,
|
||||
AgentTargetID: agentTargetID,
|
||||
AgentProvider: agentProvider,
|
||||
ModelPlanID: modelPlanID,
|
||||
Model: model,
|
||||
ReasoningIntensity: reasoningIntensity,
|
||||
AgentUserID: firstNonEmpty(input.AgentUserID, actorUserID),
|
||||
AgentSessionID: strings.TrimSpace(input.AgentSessionID),
|
||||
Status: StatusRunning,
|
||||
ExecutionDirectory: executionDirectory,
|
||||
CreatedAtUnixMS: now,
|
||||
StartedAtUnixMS: now,
|
||||
UpdatedAtUnixMS: now,
|
||||
}
|
||||
if run.RunID == "" {
|
||||
return PreparedRun{}, ErrInvalidArgument
|
||||
}
|
||||
return PreparedRun{Run: run, Task: *task, TaskIsNew: taskIsNew, Issue: issue}, nil
|
||||
}
|
||||
|
||||
func taskDependenciesAccepted(ctx context.Context, store Store, workspaceID string, issueID string, task Task) bool {
|
||||
if len(task.DependencyTaskIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, dependencyID := range task.DependencyTaskIDs {
|
||||
dependency, err := store.GetTask(ctx, workspaceID, issueID, dependencyID)
|
||||
if err != nil || dependency.Status != StatusCompleted || dependency.AcceptanceState != AcceptanceUserAccepted {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func legacyAgentTargetIDForProvider(provider string) string {
|
||||
descriptor, ok := providerregistry.Find(provider)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return descriptor.Target.ID
|
||||
}
|
||||
|
||||
func agentProviderForAgentTargetID(agentTargetID string) string {
|
||||
const localPrefix = "local:"
|
||||
agentTargetID = strings.TrimSpace(agentTargetID)
|
||||
if strings.HasPrefix(agentTargetID, localPrefix) {
|
||||
return strings.TrimPrefix(agentTargetID, localPrefix)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s Service) prepareIssueRunTask(ctx context.Context, store Store, issue Issue, actorUserID string) (Task, bool, error) {
|
||||
tasks, err := store.ListTasks(ctx, TaskListFilter{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
IssueID: issue.IssueID,
|
||||
ReturnAll: true,
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
if len(tasks.Items) > 0 {
|
||||
return tasks.Items[0], false, nil
|
||||
}
|
||||
prepared, err := s.buildTasks(issue, actorUserID, []CreateTaskItemInput{{
|
||||
Title: issue.Title,
|
||||
}})
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
if len(prepared) != 1 {
|
||||
return Task{}, false, ErrInvalidArgument
|
||||
}
|
||||
return prepared[0], true, nil
|
||||
}
|
||||
|
||||
func findIssueRunTaskID(ctx context.Context, store Store, workspaceID string, issueID string, runID string) (string, error) {
|
||||
runs, err := store.ListRuns(ctx, workspaceID, issueID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.RunID == runID {
|
||||
return run.TaskID, nil
|
||||
}
|
||||
}
|
||||
return "", ErrRunNotFound
|
||||
}
|
||||
|
||||
func (s Service) CompleteRun(ctx context.Context, input CompleteRunInput) (Run, []RunOutput, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
taskID := strings.TrimSpace(input.TaskID)
|
||||
runID := strings.TrimSpace(input.RunID)
|
||||
if workspaceID == "" || issueID == "" || runID == "" || strings.TrimSpace(input.ActorUserID) == "" {
|
||||
return Run{}, nil, ErrInvalidArgument
|
||||
}
|
||||
status, ok := NormalizeRunCompletionStatus(input.Status)
|
||||
if !ok {
|
||||
return Run{}, nil, ErrInvalidArgument
|
||||
}
|
||||
usage, ok := NormalizeTokenUsage(input.Usage)
|
||||
if !ok {
|
||||
return Run{}, nil, ErrInvalidArgument
|
||||
}
|
||||
if input.HasRemainingQuotaPercent && (input.RemainingQuotaPercent < 0 || input.RemainingQuotaPercent > 100) {
|
||||
return Run{}, nil, ErrInvalidArgument
|
||||
}
|
||||
if taskID == "" {
|
||||
resolvedTaskID, err := findIssueRunTaskID(ctx, store, workspaceID, issueID, runID)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
taskID = resolvedTaskID
|
||||
}
|
||||
issue, task, err := getRunParent(ctx, store, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
run, err := store.GetRun(ctx, workspaceID, issueID, taskID, runID)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
if run.Status == StatusCompleted || run.Status == StatusFailed || run.Status == StatusCanceled {
|
||||
outputs, err := store.ListRunOutputs(ctx, workspaceID, issueID, taskID, runID)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
return run, outputs, nil
|
||||
}
|
||||
now := s.nowUnixMS()
|
||||
outputs := make([]RunOutput, 0, len(input.Outputs))
|
||||
seen := make(map[string]struct{}, len(input.Outputs))
|
||||
for _, output := range input.Outputs {
|
||||
outputPath := strings.TrimSpace(output.Path)
|
||||
if outputPath == "" {
|
||||
return Run{}, nil, ErrInvalidArgument
|
||||
}
|
||||
outputID := s.resolveID(IDKindRunOutput, output.OutputID)
|
||||
if _, exists := seen[outputID]; exists {
|
||||
outputID = s.resolveID(IDKindRunOutput, "")
|
||||
}
|
||||
seen[outputID] = struct{}{}
|
||||
displayName := strings.TrimSpace(output.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = path.Base(outputPath)
|
||||
}
|
||||
outputs = append(outputs, RunOutput{
|
||||
OutputID: outputID,
|
||||
RunID: run.RunID,
|
||||
TaskID: run.TaskID,
|
||||
IssueID: run.IssueID,
|
||||
WorkspaceID: run.WorkspaceID,
|
||||
Path: outputPath,
|
||||
DisplayName: displayName,
|
||||
MediaType: strings.TrimSpace(output.MediaType),
|
||||
SizeBytes: maxInt64(output.SizeBytes, 0),
|
||||
CreatedAtUnixMS: now,
|
||||
})
|
||||
}
|
||||
run.Status = status
|
||||
run.Usage = usage
|
||||
run.Summary = strings.TrimSpace(input.Summary)
|
||||
run.ErrorMessage = strings.TrimSpace(input.ErrorMessage)
|
||||
run.CompletedAtUnixMS = now
|
||||
run.UpdatedAtUnixMS = now
|
||||
completed, savedOutputs, err := store.CompleteRun(ctx, run, outputs)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
if task != nil {
|
||||
task.Status = TaskStatusForCompletedRun(status)
|
||||
// A finished executor turn is the Agent's own completion claim, never
|
||||
// evidence that an independent review passed or that the user accepted.
|
||||
task.AcceptanceState = AcceptanceAgentClaimed
|
||||
if status == StatusCompleted {
|
||||
task.AcceptanceSummary = strings.TrimSpace(input.Summary)
|
||||
}
|
||||
task.LatestRunID = completed.RunID
|
||||
task.UpdatedAtUnixMS = now
|
||||
if _, err := store.UpdateTask(ctx, *task); err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
projected, err := store.RecalculateIssueProjection(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
issue = projected
|
||||
} else {
|
||||
issue.Status = TaskStatusForCompletedRun(status)
|
||||
issue.UpdatedAtUnixMS = now
|
||||
if _, err := store.UpdateIssue(ctx, issue); err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
}
|
||||
issue.Budget.ConsumedTokens += usage.Total()
|
||||
if input.HasRemainingQuotaPercent {
|
||||
issue.Budget.RemainingQuotaPercent = input.RemainingQuotaPercent
|
||||
issue.Budget.HasRemainingQuota = true
|
||||
}
|
||||
if issue.Budget.TokenLimit > 0 && issue.Budget.ConsumedTokens >= issue.Budget.TokenLimit {
|
||||
issue.Budget.Status = BudgetStatusSoftLimited
|
||||
}
|
||||
if issue.Budget.HasRemainingQuota && issue.Budget.RemainingQuotaPercent <= issue.Budget.QuotaWaterlinePercent {
|
||||
issue.Budget.Status = BudgetStatusSoftLimited
|
||||
}
|
||||
issue.UpdatedAtUnixMS = now
|
||||
if _, err := store.UpdateIssue(ctx, issue); err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, now); err != nil {
|
||||
return Run{}, nil, err
|
||||
}
|
||||
return completed, savedOutputs, nil
|
||||
}
|
||||
|
||||
func ensureRunParentExists(ctx context.Context, store Store, workspaceID string, issueID string, taskID string) error {
|
||||
_, _, err := getRunParent(ctx, store, workspaceID, issueID, taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
func getRunParent(ctx context.Context, store Store, workspaceID string, issueID string, taskID string) (Issue, *Task, error) {
|
||||
if taskID != "" {
|
||||
task, err := store.GetTask(ctx, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, task.WorkspaceID, task.IssueID)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
return issue, &task, nil
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
return issue, nil, nil
|
||||
}
|
||||
|
||||
func (s Service) AddContextRefs(ctx context.Context, input AddContextRefsInput) ([]ContextRef, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
parentKind, ok := NormalizeContextRefParentKind(input.ParentKind)
|
||||
if workspaceID == "" || issueID == "" || !ok {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
taskID := strings.TrimSpace(input.TaskID)
|
||||
if parentKind == ContextRefParentTask && taskID == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs, err := s.buildContextRefs(issue, taskID, parentKind, input.Refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
saved, err := store.AddContextRefs(ctx, refs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, refs[0].CreatedAtUnixMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (s Service) buildContextRefs(
|
||||
issue Issue,
|
||||
taskID string,
|
||||
parentKind ContextRefParentKind,
|
||||
input []AddContextRefInput,
|
||||
) ([]ContextRef, error) {
|
||||
refs := make([]ContextRef, 0, len(input))
|
||||
now := s.nowUnixMS()
|
||||
for _, ref := range input {
|
||||
refPath := strings.TrimSpace(ref.Path)
|
||||
if refPath == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
displayName := strings.TrimSpace(ref.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = path.Base(refPath)
|
||||
}
|
||||
contextRefID := s.resolveID(IDKindContextRef, ref.ContextRefID)
|
||||
if contextRefID == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
refs = append(refs, ContextRef{
|
||||
ContextRefID: contextRefID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
IssueID: issue.IssueID,
|
||||
TaskID: taskID,
|
||||
ParentKind: parentKind,
|
||||
RefType: strings.TrimSpace(ref.RefType),
|
||||
Path: refPath,
|
||||
DisplayName: displayName,
|
||||
CreatedAtUnixMS: now,
|
||||
})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func (s Service) RemoveContextRef(ctx context.Context, input RemoveContextRefInput) (bool, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
taskID := strings.TrimSpace(input.TaskID)
|
||||
contextRefID := strings.TrimSpace(input.ContextRefID)
|
||||
parentKind, ok := NormalizeContextRefParentKind(input.ParentKind)
|
||||
if workspaceID == "" || issueID == "" || contextRefID == "" || !ok {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
if parentKind == ContextRefParentTask && taskID == "" {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
if parentKind == ContextRefParentIssue {
|
||||
taskID = ""
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return false, err
|
||||
}
|
||||
removed, err := store.RemoveContextRef(ctx, workspaceID, issueID, taskID, parentKind, contextRefID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !removed {
|
||||
return false, ErrContextRefNotFound
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, s.nowUnixMS()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s Service) store() (Store, error) {
|
||||
if s.Store == nil {
|
||||
return nil, ErrStoreNotConfigured
|
||||
}
|
||||
return s.Store, nil
|
||||
}
|
||||
|
||||
func (s Service) nowUnixMS() int64 {
|
||||
clock := s.Clock
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
return clock().UnixMilli()
|
||||
}
|
||||
|
||||
func (s Service) resolveID(kind IDKind, value string) string {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if s.IDGenerator != nil {
|
||||
return strings.TrimSpace(s.IDGenerator(kind))
|
||||
}
|
||||
return randomID(kind)
|
||||
}
|
||||
|
||||
func randomID(kind IDKind) string {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(kind) + "-" + hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
func maxInt64(value int64, floor int64) int64 {
|
||||
if value < floor {
|
||||
return floor
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package workspaceissues
|
||||
|
||||
import "context"
|
||||
|
||||
type issueWithContextRefsStore interface {
|
||||
CreateIssueWithContextRefs(context.Context, Issue, []ContextRef) (Issue, []ContextRef, error)
|
||||
}
|
||||
|
||||
// CreateIssueWithTasks validates and persists a new Issue and its initial task
|
||||
// graph as one store operation. Callers never observe a partially created Plan
|
||||
// Issue, and no compensating delete is required when a task insert fails.
|
||||
func (s Service) CreateIssueWithTasks(ctx context.Context, input CreateIssueWithTasksInput) (Issue, []Task, error) {
|
||||
issue, tasks, err := s.PrepareIssueWithTasks(ctx, input)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
return store.CreateIssueWithTasks(ctx, issue, tasks)
|
||||
}
|
||||
|
||||
// PrepareIssueWithTasks validates and normalizes a new Issue graph without
|
||||
// persisting it. Product adapters use this when their owning transaction must
|
||||
// atomically include additional durable state alongside the reusable Issue
|
||||
// aggregate.
|
||||
func (s Service) PrepareIssueWithTasks(ctx context.Context, input CreateIssueWithTasksInput) (Issue, []Task, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
if len(input.Tasks) == 0 {
|
||||
return Issue{}, nil, ErrInvalidArgument
|
||||
}
|
||||
issue, err := s.buildIssue(ctx, store, input.Issue)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
tasks, err := s.buildTasks(issue, input.Issue.ActorUserID, input.Tasks)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
if !ValidateTaskDependencyGraph(tasks) {
|
||||
return Issue{}, nil, ErrInvalidArgument
|
||||
}
|
||||
issue.TaskCount = len(tasks)
|
||||
issue.NotStartedCount = len(tasks)
|
||||
if issue.Budget.Mode == BudgetModeAuto {
|
||||
issue.Budget.TokenLimit = CompileAutoTokenBudgetWithHistory(
|
||||
issue.TaskCount,
|
||||
issue.ExecutionProfile,
|
||||
input.Issue.AutoTokenBudgetHistoryHint,
|
||||
)
|
||||
}
|
||||
return issue, tasks, nil
|
||||
}
|
||||
|
||||
// CreateIssueWithContextRefs persists a new Issue and its Issue-scoped context
|
||||
// references in one store transaction. This is used for managed attachment
|
||||
// files so a committed Issue can never be observed without its durable refs.
|
||||
func (s Service) CreateIssueWithContextRefs(
|
||||
ctx context.Context,
|
||||
input CreateIssueWithContextRefsInput,
|
||||
) (Issue, []ContextRef, error) {
|
||||
issue, refs, err := s.PrepareIssueWithContextRefs(ctx, input)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
atomicStore, ok := store.(issueWithContextRefsStore)
|
||||
if !ok {
|
||||
return Issue{}, nil, ErrStoreNotConfigured
|
||||
}
|
||||
return atomicStore.CreateIssueWithContextRefs(ctx, issue, refs)
|
||||
}
|
||||
|
||||
// PrepareIssueWithContextRefs validates the Issue and its references without
|
||||
// persistence, allowing a product-owned adapter to include extra durable state
|
||||
// in the same transaction.
|
||||
func (s Service) PrepareIssueWithContextRefs(
|
||||
ctx context.Context,
|
||||
input CreateIssueWithContextRefsInput,
|
||||
) (Issue, []ContextRef, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
if len(input.Refs) == 0 {
|
||||
return Issue{}, nil, ErrInvalidArgument
|
||||
}
|
||||
issue, err := s.buildIssue(ctx, store, input.Issue)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
refs, err := s.buildContextRefs(issue, "", ContextRefParentIssue, input.Refs)
|
||||
if err != nil {
|
||||
return Issue{}, nil, err
|
||||
}
|
||||
return issue, refs, nil
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s Service) ListIssues(ctx context.Context, filter IssueListFilter) (IssueList, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return IssueList{}, err
|
||||
}
|
||||
filter.WorkspaceID = strings.TrimSpace(filter.WorkspaceID)
|
||||
filter.TopicID = strings.TrimSpace(filter.TopicID)
|
||||
if filter.WorkspaceID == "" || filter.TopicID == "" {
|
||||
return IssueList{}, ErrInvalidArgument
|
||||
}
|
||||
if filter.StatusFilter != "" {
|
||||
if _, ok := NormalizeStatus(string(filter.StatusFilter)); !ok {
|
||||
return IssueList{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
filter.SearchQuery = strings.TrimSpace(filter.SearchQuery)
|
||||
if _, err := store.GetTopic(ctx, filter.WorkspaceID, filter.TopicID); err != nil {
|
||||
return IssueList{}, err
|
||||
}
|
||||
list, err := store.ListIssues(ctx, filter)
|
||||
if err != nil {
|
||||
return IssueList{}, err
|
||||
}
|
||||
list.NextPageToken = EncodeIssueListCursorToken(list.NextCursor)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s Service) CreateIssue(ctx context.Context, input CreateIssueInput) (Issue, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
issue, err := s.buildIssue(ctx, store, input)
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
created, err := store.CreateIssue(ctx, issue)
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, issue.WorkspaceID, issue.TopicID, issue.UpdatedAtUnixMS); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s Service) buildIssue(ctx context.Context, store Store, input CreateIssueInput) (Issue, error) {
|
||||
if input.SequentialExecution && input.ParallelExecution {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
topicID := strings.TrimSpace(input.TopicID)
|
||||
actorUserID := strings.TrimSpace(input.ActorUserID)
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if workspaceID == "" || topicID == "" || actorUserID == "" || title == "" {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
if _, err := store.GetTopic(ctx, workspaceID, topicID); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
planningSource := PlanningSourceManual
|
||||
if strings.TrimSpace(input.PlanningSource) != "" {
|
||||
var ok bool
|
||||
planningSource, ok = NormalizePlanningSource(input.PlanningSource)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
profile := DefaultExecutionProfile()
|
||||
if input.HasExecutionProfile {
|
||||
var ok bool
|
||||
profile, ok = NormalizeExecutionProfile(input.ExecutionProfile)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
budget := DefaultBudget()
|
||||
if input.HasBudget {
|
||||
var ok bool
|
||||
budget, ok = NormalizeBudget(input.Budget)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
now := s.nowUnixMS()
|
||||
issue := Issue{
|
||||
IssueID: s.resolveID(IDKindIssue, input.IssueID),
|
||||
TopicID: topicID,
|
||||
WorkspaceID: workspaceID,
|
||||
Title: title,
|
||||
Content: strings.TrimSpace(input.Content),
|
||||
SearchText: TrimSearchText(input.Content),
|
||||
Status: StatusNotStarted,
|
||||
PlanningSource: planningSource,
|
||||
SourceSessionID: strings.TrimSpace(input.SourceSessionID),
|
||||
SequentialExecution: input.SequentialExecution,
|
||||
ParallelExecution: input.ParallelExecution,
|
||||
ExecutionProfile: profile,
|
||||
Budget: budget,
|
||||
CreatorUserID: actorUserID,
|
||||
CreatedAtUnixMS: now,
|
||||
UpdatedAtUnixMS: now,
|
||||
}
|
||||
if issue.IssueID == "" {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
func (s Service) UpdateIssue(ctx context.Context, input UpdateIssueInput) (Issue, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
if workspaceID == "" || issueID == "" || strings.TrimSpace(input.ActorUserID) == "" {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
if input.HasTitle {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
issue.Title = title
|
||||
}
|
||||
if input.HasContent {
|
||||
issue.Content = strings.TrimSpace(input.Content)
|
||||
issue.SearchText = TrimSearchText(input.Content)
|
||||
}
|
||||
if input.HasStatus {
|
||||
status, ok := NormalizeStatus(input.Status)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
issue.Status = status
|
||||
}
|
||||
if input.HasDispatchPaused {
|
||||
issue.DispatchPaused = input.DispatchPaused
|
||||
}
|
||||
if input.HasExecutionProfile {
|
||||
profile, ok := NormalizeExecutionProfile(input.ExecutionProfile)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
issue.ExecutionProfile = profile
|
||||
if issue.Budget.Mode == BudgetModeAuto {
|
||||
issue.Budget.TokenLimit = CompileAutoTokenBudget(maxInt(issue.TaskCount, 1), profile)
|
||||
}
|
||||
}
|
||||
if input.HasBudget {
|
||||
budget, ok := NormalizeBudget(input.Budget)
|
||||
if !ok {
|
||||
return Issue{}, ErrInvalidArgument
|
||||
}
|
||||
budget.ConsumedTokens = issue.Budget.ConsumedTokens
|
||||
if !budget.HasRemainingQuota {
|
||||
budget.RemainingQuotaPercent = issue.Budget.RemainingQuotaPercent
|
||||
budget.HasRemainingQuota = issue.Budget.HasRemainingQuota
|
||||
}
|
||||
if budget.Mode == BudgetModeAuto {
|
||||
budget.TokenLimit = CompileAutoTokenBudget(maxInt(issue.TaskCount, 1), issue.ExecutionProfile)
|
||||
}
|
||||
budget.Status = BudgetStatusActive
|
||||
if budget.TokenLimit > 0 && budget.ConsumedTokens >= budget.TokenLimit {
|
||||
budget.Status = BudgetStatusSoftLimited
|
||||
}
|
||||
if budget.HasRemainingQuota && budget.RemainingQuotaPercent <= budget.QuotaWaterlinePercent {
|
||||
budget.Status = BudgetStatusSoftLimited
|
||||
}
|
||||
issue.Budget = budget
|
||||
}
|
||||
issue.UpdatedAtUnixMS = s.nowUnixMS()
|
||||
updated, err := store.UpdateIssue(ctx, issue)
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, issue.UpdatedAtUnixMS); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s Service) GetIssueDetail(ctx context.Context, workspaceID string, issueID string) (IssueDetail, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
if workspaceID == "" || issueID == "" {
|
||||
return IssueDetail{}, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
tasks, err := store.ListTasks(ctx, TaskListFilter{
|
||||
WorkspaceID: workspaceID,
|
||||
IssueID: issueID,
|
||||
ReturnAll: true,
|
||||
})
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
refs, err := store.ListContextRefs(ctx, workspaceID, issueID, "", ContextRefParentIssue)
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
runs, err := store.ListRuns(ctx, workspaceID, issueID, "")
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
var latestRun *Run
|
||||
if len(runs) > 0 {
|
||||
latestRun = &runs[0]
|
||||
}
|
||||
outputs := make([]RunOutput, 0)
|
||||
seenOutputPaths := map[string]struct{}{}
|
||||
for _, run := range runs {
|
||||
runOutputs, err := store.ListRunOutputs(ctx, workspaceID, issueID, run.TaskID, run.RunID)
|
||||
if err != nil {
|
||||
return IssueDetail{}, err
|
||||
}
|
||||
for _, output := range runOutputs {
|
||||
outputPath := strings.TrimSpace(output.Path)
|
||||
if _, exists := seenOutputPaths[outputPath]; exists {
|
||||
continue
|
||||
}
|
||||
seenOutputPaths[outputPath] = struct{}{}
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
}
|
||||
return IssueDetail{
|
||||
Issue: issue,
|
||||
Tasks: tasks.Items,
|
||||
ContextRefs: refs,
|
||||
LatestRun: latestRun,
|
||||
RecentRuns: runs,
|
||||
LatestOutputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultIssueOutputSearchLimit = 50
|
||||
maxIssueOutputSearchLimit = 200
|
||||
)
|
||||
|
||||
// SearchIssueOutputs searches produced output files across one workspace by file
|
||||
// name, deduplicated by path and ordered by recency. IssueID / TopicID optionally
|
||||
// scope the search; IssueID wins when both are set.
|
||||
// normalizeRunOutputFilters trims、去重、丢弃空白的「文件类型筛选分类」id。
|
||||
func normalizeRunOutputFilters(filters []string) []string {
|
||||
if len(filters) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
filter = strings.TrimSpace(filter)
|
||||
if filter == "" || seen[filter] {
|
||||
continue
|
||||
}
|
||||
seen[filter] = true
|
||||
out = append(out, filter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s Service) SearchIssueOutputs(ctx context.Context, params RunOutputSearchParams) ([]RunOutputSearchHit, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params.WorkspaceID = strings.TrimSpace(params.WorkspaceID)
|
||||
params.Query = strings.TrimSpace(params.Query)
|
||||
params.IssueID = strings.TrimSpace(params.IssueID)
|
||||
params.TopicID = strings.TrimSpace(params.TopicID)
|
||||
params.Filters = normalizeRunOutputFilters(params.Filters)
|
||||
// 筛选与搜索是同一能力:关键词与筛选同时为空才算无效查询。仅选类型筛选(Query 空)时
|
||||
// 由 store 按类型 list-all。
|
||||
if params.WorkspaceID == "" || (params.Query == "" && len(params.Filters) == 0) {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
if params.Limit <= 0 {
|
||||
params.Limit = defaultIssueOutputSearchLimit
|
||||
}
|
||||
if params.Limit > maxIssueOutputSearchLimit {
|
||||
params.Limit = maxIssueOutputSearchLimit
|
||||
}
|
||||
return store.SearchRunOutputs(ctx, params)
|
||||
}
|
||||
|
||||
func (s Service) DeleteIssue(ctx context.Context, workspaceID string, issueID string, actorUserID string) (bool, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
actorUserID = strings.TrimSpace(actorUserID)
|
||||
if workspaceID == "" || issueID == "" || actorUserID == "" {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return false, err
|
||||
}
|
||||
removed, err := store.DeleteIssue(ctx, workspaceID, issueID, actorUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !removed {
|
||||
return false, ErrIssueNotFound
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, s.nowUnixMS()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s Service) ListTasks(ctx context.Context, filter TaskListFilter) (TaskList, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return TaskList{}, err
|
||||
}
|
||||
filter.WorkspaceID = strings.TrimSpace(filter.WorkspaceID)
|
||||
filter.IssueID = strings.TrimSpace(filter.IssueID)
|
||||
if filter.WorkspaceID == "" || filter.IssueID == "" {
|
||||
return TaskList{}, ErrInvalidArgument
|
||||
}
|
||||
if filter.StatusFilter != "" {
|
||||
if _, ok := NormalizeStatus(string(filter.StatusFilter)); !ok {
|
||||
return TaskList{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
filter.SearchQuery = strings.TrimSpace(filter.SearchQuery)
|
||||
if _, err := store.GetIssue(ctx, filter.WorkspaceID, filter.IssueID); err != nil {
|
||||
return TaskList{}, err
|
||||
}
|
||||
list, err := store.ListTasks(ctx, filter)
|
||||
if err != nil {
|
||||
return TaskList{}, err
|
||||
}
|
||||
list.NextPageToken = EncodeTaskListCursorToken(list.NextCursor)
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s Service) CreateTask(ctx context.Context, input CreateTaskInput) (Task, error) {
|
||||
tasks, err := s.CreateTasks(ctx, CreateTasksInput{
|
||||
IssueID: input.IssueID,
|
||||
WorkspaceID: input.WorkspaceID,
|
||||
ActorUserID: input.ActorUserID,
|
||||
Tasks: []CreateTaskItemInput{{
|
||||
TaskID: input.TaskID,
|
||||
Title: input.Title,
|
||||
Content: input.Content,
|
||||
Priority: input.Priority,
|
||||
DueAtUnixMS: input.DueAtUnixMS,
|
||||
AgentTargetID: input.AgentTargetID,
|
||||
ModelPlanID: input.ModelPlanID,
|
||||
Model: input.Model,
|
||||
PermissionModeID: input.PermissionModeID,
|
||||
ReasoningEffort: input.ReasoningEffort,
|
||||
ExecutionDirectory: input.ExecutionDirectory,
|
||||
DependencyTaskIDs: input.DependencyTaskIDs,
|
||||
Parallelizable: input.Parallelizable,
|
||||
AutoAccept: input.AutoAccept,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
return tasks[0], nil
|
||||
}
|
||||
|
||||
func (s Service) CreateTasks(ctx context.Context, input CreateTasksInput) ([]Task, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
actorUserID := strings.TrimSpace(input.ActorUserID)
|
||||
if workspaceID == "" || issueID == "" || actorUserID == "" || len(input.Tasks) == 0 {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks, err := s.buildTasks(issue, actorUserID, input.Tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := store.ListTasks(ctx, TaskListFilter{WorkspaceID: workspaceID, IssueID: issueID, ReturnAll: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph := append(append([]Task(nil), existing.Items...), tasks...)
|
||||
if !ValidateTaskDependencyGraph(graph) {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
created, err := store.AppendTasks(ctx, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projected, err := store.RecalculateIssueProjection(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if projected.Budget.Mode == BudgetModeAuto {
|
||||
projected.Budget.TokenLimit = CompileAutoTokenBudget(projected.TaskCount, projected.ExecutionProfile)
|
||||
if _, err := store.UpdateIssue(ctx, projected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, s.nowUnixMS()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s Service) buildTasks(issue Issue, actorUserID string, items []CreateTaskItemInput) ([]Task, error) {
|
||||
now := s.nowUnixMS()
|
||||
tasks := make([]Task, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
agentTargetID := strings.TrimSpace(item.AgentTargetID)
|
||||
modelPlanID := strings.TrimSpace(item.ModelPlanID)
|
||||
model := strings.TrimSpace(item.Model)
|
||||
if agentTargetID == "" && (modelPlanID != "" || model != "") {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
task := Task{
|
||||
TaskID: s.resolveID(IDKindTask, item.TaskID),
|
||||
IssueID: issue.IssueID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
Title: title,
|
||||
Content: strings.TrimSpace(item.Content),
|
||||
SearchText: TrimSearchText(item.Content),
|
||||
Status: StatusNotStarted,
|
||||
Priority: NormalizePriority(item.Priority),
|
||||
DueAtUnixMS: maxInt64(item.DueAtUnixMS, 0),
|
||||
AgentTargetID: agentTargetID,
|
||||
ModelPlanID: modelPlanID,
|
||||
Model: model,
|
||||
PermissionModeID: strings.TrimSpace(item.PermissionModeID),
|
||||
ReasoningEffort: strings.TrimSpace(item.ReasoningEffort),
|
||||
ExecutionDirectory: strings.TrimSpace(item.ExecutionDirectory),
|
||||
DependencyTaskIDs: NormalizeDependencyTaskIDs(item.DependencyTaskIDs),
|
||||
Parallelizable: item.Parallelizable,
|
||||
AutoAccept: item.AutoAccept,
|
||||
AcceptanceState: AcceptanceAgentClaimed,
|
||||
CreatorUserID: actorUserID,
|
||||
CreatedAtUnixMS: now,
|
||||
UpdatedAtUnixMS: now,
|
||||
}
|
||||
if task.TaskID == "" {
|
||||
return nil, ErrInvalidArgument
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s Service) UpdateTask(ctx context.Context, input UpdateTaskInput) (Task, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
issueID := strings.TrimSpace(input.IssueID)
|
||||
taskID := strings.TrimSpace(input.TaskID)
|
||||
if workspaceID == "" || issueID == "" || taskID == "" || strings.TrimSpace(input.ActorUserID) == "" {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
task, err := store.GetTask(ctx, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if input.HasTitle {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
task.Title = title
|
||||
}
|
||||
if input.HasContent {
|
||||
task.Content = strings.TrimSpace(input.Content)
|
||||
task.SearchText = TrimSearchText(input.Content)
|
||||
}
|
||||
if input.HasStatus {
|
||||
status, ok := NormalizeStatus(input.Status)
|
||||
if !ok {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
task.Status = status
|
||||
}
|
||||
if input.HasPriority {
|
||||
task.Priority = NormalizePriority(input.Priority)
|
||||
}
|
||||
if input.HasDueAt {
|
||||
task.DueAtUnixMS = input.DueAtUnixMS
|
||||
}
|
||||
if input.HasSortIndex {
|
||||
if input.SortIndex < 0 {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
task.SortIndex = input.SortIndex
|
||||
}
|
||||
if input.HasAgentTargetID {
|
||||
task.AgentTargetID = strings.TrimSpace(input.AgentTargetID)
|
||||
}
|
||||
if input.HasModelPlanID {
|
||||
task.ModelPlanID = strings.TrimSpace(input.ModelPlanID)
|
||||
}
|
||||
if input.HasModel {
|
||||
task.Model = strings.TrimSpace(input.Model)
|
||||
}
|
||||
if task.AgentTargetID == "" && (task.ModelPlanID != "" || task.Model != "") {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
if input.HasExecutionDirectory {
|
||||
task.ExecutionDirectory = strings.TrimSpace(input.ExecutionDirectory)
|
||||
}
|
||||
if input.HasDependencyTaskIDs {
|
||||
task.DependencyTaskIDs = NormalizeDependencyTaskIDs(input.DependencyTaskIDs)
|
||||
}
|
||||
if input.HasParallelizable {
|
||||
task.Parallelizable = input.Parallelizable
|
||||
}
|
||||
if input.HasAutoAccept {
|
||||
task.AutoAccept = input.AutoAccept
|
||||
}
|
||||
if task.AcceptanceState == "" {
|
||||
if task.Status == StatusCompleted || task.Status == StatusPendingAcceptance {
|
||||
task.AcceptanceState = AcceptanceAutoChecked
|
||||
} else {
|
||||
task.AcceptanceState = AcceptanceAgentClaimed
|
||||
}
|
||||
}
|
||||
if input.HasAcceptanceState {
|
||||
next, ok := NormalizeAcceptanceState(input.AcceptanceState)
|
||||
if !ok || !CanTransitionAcceptance(task.AcceptanceState, next) {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
task.AcceptanceState = next
|
||||
}
|
||||
if input.HasAcceptanceSummary {
|
||||
task.AcceptanceSummary = strings.TrimSpace(input.AcceptanceSummary)
|
||||
}
|
||||
if input.HasStatus {
|
||||
switch task.Status {
|
||||
case StatusCompleted:
|
||||
if !input.HasAcceptanceState && CanTransitionAcceptance(task.AcceptanceState, AcceptanceUserAccepted) {
|
||||
task.AcceptanceState = AcceptanceUserAccepted
|
||||
}
|
||||
if task.AcceptanceState != AcceptanceUserAccepted {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
case StatusNotStarted:
|
||||
task.AcceptanceState = AcceptanceAgentClaimed
|
||||
task.AcceptanceSummary = ""
|
||||
case StatusPendingAcceptance:
|
||||
// An accepted task cannot be pushed back to pending acceptance:
|
||||
// that write would keep user_accepted on a non-completed task and
|
||||
// wedge the dispatch frontier. Rework goes through not_started.
|
||||
if task.AcceptanceState == AcceptanceUserAccepted {
|
||||
return Task{}, ErrInvalidArgument
|
||||
}
|
||||
}
|
||||
}
|
||||
task.UpdatedAtUnixMS = s.nowUnixMS()
|
||||
updated, err := store.UpdateTask(ctx, task)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if _, err := store.RecalculateIssueProjection(ctx, workspaceID, issueID); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, task.UpdatedAtUnixMS); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s Service) GetTaskDetail(ctx context.Context, workspaceID string, issueID string, taskID string) (TaskDetail, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return TaskDetail{}, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
if workspaceID == "" || issueID == "" || taskID == "" {
|
||||
return TaskDetail{}, ErrInvalidArgument
|
||||
}
|
||||
task, err := store.GetTask(ctx, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return TaskDetail{}, err
|
||||
}
|
||||
refs, err := store.ListContextRefs(ctx, workspaceID, issueID, taskID, ContextRefParentTask)
|
||||
if err != nil {
|
||||
return TaskDetail{}, err
|
||||
}
|
||||
runs, err := store.ListRuns(ctx, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return TaskDetail{}, err
|
||||
}
|
||||
var latestRun *Run
|
||||
if len(runs) > 0 {
|
||||
latestRun = &runs[0]
|
||||
}
|
||||
outputs, err := store.ListLatestRunOutputs(ctx, workspaceID, issueID, taskID)
|
||||
if err != nil {
|
||||
return TaskDetail{}, err
|
||||
}
|
||||
return TaskDetail{
|
||||
Task: task,
|
||||
ContextRefs: refs,
|
||||
LatestRun: latestRun,
|
||||
RecentRuns: runs,
|
||||
LatestOutputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) DeleteTask(ctx context.Context, workspaceID string, issueID string, taskID string, actorUserID string) (bool, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
issueID = strings.TrimSpace(issueID)
|
||||
taskID = strings.TrimSpace(taskID)
|
||||
actorUserID = strings.TrimSpace(actorUserID)
|
||||
if workspaceID == "" || issueID == "" || taskID == "" || actorUserID == "" {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
issue, err := store.GetIssue(ctx, workspaceID, issueID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := RejectManagedIssueMutation(issue); err != nil {
|
||||
return false, err
|
||||
}
|
||||
removed, err := store.DeleteTask(ctx, workspaceID, issueID, taskID, actorUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !removed {
|
||||
return false, ErrTaskNotFound
|
||||
}
|
||||
if _, err := store.RecalculateIssueProjection(ctx, workspaceID, issueID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := store.TouchTopicActivity(ctx, workspaceID, issue.TopicID, s.nowUnixMS()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
package workspaceissues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s Service) ListTopics(ctx context.Context, workspaceID string) (TopicList, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return TopicList{}, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
if workspaceID == "" {
|
||||
return TopicList{}, ErrInvalidArgument
|
||||
}
|
||||
return store.ListTopics(ctx, workspaceID)
|
||||
}
|
||||
|
||||
func (s Service) CreateTopic(ctx context.Context, input CreateTopicInput) (Topic, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Topic{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
actorUserID := strings.TrimSpace(input.ActorUserID)
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if workspaceID == "" || actorUserID == "" || title == "" {
|
||||
return Topic{}, ErrInvalidArgument
|
||||
}
|
||||
now := s.nowUnixMS()
|
||||
topic := Topic{
|
||||
TopicID: s.resolveID(IDKindTopic, input.TopicID),
|
||||
WorkspaceID: workspaceID,
|
||||
Title: title,
|
||||
Summary: strings.TrimSpace(input.Summary),
|
||||
LastActivityAtUnixMS: now,
|
||||
CreatedAtUnixMS: now,
|
||||
UpdatedAtUnixMS: now,
|
||||
}
|
||||
if topic.TopicID == "" {
|
||||
return Topic{}, ErrInvalidArgument
|
||||
}
|
||||
return store.CreateTopic(ctx, topic)
|
||||
}
|
||||
|
||||
func (s Service) UpdateTopic(ctx context.Context, input UpdateTopicInput) (Topic, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return Topic{}, err
|
||||
}
|
||||
workspaceID := strings.TrimSpace(input.WorkspaceID)
|
||||
topicID := strings.TrimSpace(input.TopicID)
|
||||
if workspaceID == "" || topicID == "" || strings.TrimSpace(input.ActorUserID) == "" {
|
||||
return Topic{}, ErrInvalidArgument
|
||||
}
|
||||
topic, err := store.GetTopic(ctx, workspaceID, topicID)
|
||||
if err != nil {
|
||||
return Topic{}, err
|
||||
}
|
||||
if input.HasTitle {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return Topic{}, ErrInvalidArgument
|
||||
}
|
||||
topic.Title = title
|
||||
}
|
||||
if input.HasSummary {
|
||||
topic.Summary = strings.TrimSpace(input.Summary)
|
||||
}
|
||||
if input.HasPinned {
|
||||
if input.Pinned && topic.PinnedAtUnixMS == 0 {
|
||||
topic.PinnedAtUnixMS = s.nowUnixMS()
|
||||
}
|
||||
if !input.Pinned {
|
||||
topic.PinnedAtUnixMS = 0
|
||||
}
|
||||
}
|
||||
topic.UpdatedAtUnixMS = s.nowUnixMS()
|
||||
return store.UpdateTopic(ctx, topic)
|
||||
}
|
||||
|
||||
func (s Service) DeleteTopic(ctx context.Context, workspaceID string, topicID string, actorUserID string) (bool, error) {
|
||||
store, err := s.store()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
topicID = strings.TrimSpace(topicID)
|
||||
if workspaceID == "" || topicID == "" || strings.TrimSpace(actorUserID) == "" {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
topic, err := store.GetTopic(ctx, workspaceID, topicID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if topic.IsDefault || topic.TopicID == DefaultTopicID {
|
||||
return false, ErrInvalidArgument
|
||||
}
|
||||
issues, err := store.ListIssues(ctx, IssueListFilter{
|
||||
WorkspaceID: workspaceID,
|
||||
TopicID: topicID,
|
||||
PageSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if issues.TotalCount > 0 {
|
||||
return false, ErrTopicNotEmpty
|
||||
}
|
||||
return store.DeleteTopic(ctx, workspaceID, topicID)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package workspaceissues
|
||||
|
||||
import "context"
|
||||
|
||||
type Store interface {
|
||||
ListTopics(context.Context, string) (TopicList, error)
|
||||
CreateTopic(context.Context, Topic) (Topic, error)
|
||||
GetTopic(context.Context, string, string) (Topic, error)
|
||||
UpdateTopic(context.Context, Topic) (Topic, error)
|
||||
DeleteTopic(context.Context, string, string) (bool, error)
|
||||
TouchTopicActivity(context.Context, string, string, int64) error
|
||||
|
||||
ListIssues(context.Context, IssueListFilter) (IssueList, error)
|
||||
CreateIssue(context.Context, Issue) (Issue, error)
|
||||
CreateIssueWithTasks(context.Context, Issue, []Task) (Issue, []Task, error)
|
||||
GetIssue(context.Context, string, string) (Issue, error)
|
||||
UpdateIssue(context.Context, Issue) (Issue, error)
|
||||
DeleteIssue(context.Context, string, string, string) (bool, error)
|
||||
RecalculateIssueProjection(context.Context, string, string) (Issue, error)
|
||||
|
||||
ListTasks(context.Context, TaskListFilter) (TaskList, error)
|
||||
AppendTasks(context.Context, []Task) ([]Task, error)
|
||||
GetTask(context.Context, string, string, string) (Task, error)
|
||||
UpdateTask(context.Context, Task) (Task, error)
|
||||
DeleteTask(context.Context, string, string, string, string) (bool, error)
|
||||
|
||||
AddContextRefs(context.Context, []ContextRef) ([]ContextRef, error)
|
||||
ListContextRefs(context.Context, string, string, string, ContextRefParentKind) ([]ContextRef, error)
|
||||
RemoveContextRef(context.Context, string, string, string, ContextRefParentKind, string) (bool, error)
|
||||
|
||||
CreateRun(context.Context, Run) (Run, error)
|
||||
CompleteRun(context.Context, Run, []RunOutput) (Run, []RunOutput, error)
|
||||
// ListRuns returns runs scoped to workspace. An empty issueID intentionally
|
||||
// returns workspace history, which session-linkage lookups use without
|
||||
// adding a second persistence API. taskID is only meaningful when issueID
|
||||
// is set.
|
||||
ListRuns(context.Context, string, string, string) ([]Run, error)
|
||||
ListRunningRuns(context.Context, string, int) ([]Run, error)
|
||||
GetRun(context.Context, string, string, string, string) (Run, error)
|
||||
ListRunOutputs(context.Context, string, string, string, string) ([]RunOutput, error)
|
||||
ListLatestRunOutputs(context.Context, string, string, string) ([]RunOutput, error)
|
||||
SearchRunOutputs(context.Context, RunOutputSearchParams) ([]RunOutputSearchHit, error)
|
||||
}
|
||||
Reference in New Issue
Block a user