Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

338 lines
9.9 KiB
Go

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
}