源码快照 @ 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

This commit is contained in:
agent-radar
2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 changed files with 1936476 additions and 0 deletions
+72
View File
@@ -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),
}
}
+9
View File
@@ -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
+360
View File
@@ -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
}
+424
View File
@@ -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")
}
}
+74
View File
@@ -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
}
+820
View File
@@ -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
}
+337
View File
@@ -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
}
+363
View File
@@ -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
+112
View File
@@ -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)
}
+43
View File
@@ -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)
}