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

211 lines
6.4 KiB
Go

// Package refresolve resolves a workspace-reference handle (app+group / topic+issue) into a
// flat list of artifact files. It is the shared core behind the `reference list` CLI command and
// the inline reference expansion surfaced on issue/task detail, so both go through one egress.
package refresolve
import (
"context"
"errors"
"strings"
workspaceissues "github.com/tutti-os/tutti/packages/workspace/issues"
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
)
const (
// SourceApp / SourceTask are the two reference handle sources.
SourceApp = "app"
SourceTask = "task"
// MaxFiles bounds a single resolution (defence against cycles / pathologically large trees);
// also the default when no limit is provided.
MaxFiles = 1000
// pageLimit is the per-page size when paging an app's hierarchy.
pageLimit = 200
)
// ErrInvalidSource is returned when a handle source is neither "app" nor "task".
var ErrInvalidSource = errors.New("invalid reference input: source must be 'app' or 'task'")
// AppReferences is the unified egress to a workspace app's produced artifacts.
// Satisfied by *workspaceservice.AppCenterService.
type AppReferences interface {
ListReferences(context.Context, string, string, workspacebiz.AppReferenceListInput) (workspacebiz.AppReferenceListResult, error)
}
// IssueOutputs is the unified egress to task (issue / topic) produced artifacts.
// Satisfied by workspaceservice.IssueManagerService.
type IssueOutputs interface {
GetIssueDetail(context.Context, string, string) (workspaceissues.IssueDetail, error)
SearchIssueOutputs(context.Context, workspaceissues.RunOutputSearchParams) ([]workspaceissues.RunOutputSearchHit, error)
}
// File is the normalized, source-agnostic shape both branches map into.
type File struct {
Path string
DisplayName string
SizeBytes int64
MediaType string
CreatedAtUnixMs int64
}
// Resolver expands reference handles using the same in-process egress the desktop picker uses.
type Resolver struct {
Apps AppReferences
Issues IssueOutputs
}
// Resolve flattens a single reference handle into its artifact files. limit <= 0 means MaxFiles.
func (r Resolver) Resolve(ctx context.Context, workspaceID, source, id, groupID, query string, limit int) ([]File, error) {
if limit <= 0 || limit > MaxFiles {
limit = MaxFiles
}
switch strings.TrimSpace(source) {
case SourceApp:
return r.collectAppFiles(ctx, workspaceID, id, groupID, query, limit)
case SourceTask:
return r.collectTaskFiles(ctx, workspaceID, id, groupID, query, limit)
default:
return nil, ErrInvalidSource
}
}
// collectAppFiles walks the app's reference hierarchy (group → children) breadth-first and
// flattens it to files, going through the unified AppCenterService.ListReferences egress.
func (r Resolver) collectAppFiles(ctx context.Context, workspaceID, appID, groupID, query string, limit int) ([]File, error) {
if r.Apps == nil {
return nil, ErrInvalidSource
}
files := make([]File, 0, limit)
seenFiles := make(map[string]struct{})
visitedGroups := make(map[string]struct{})
queue := []string{strings.TrimSpace(groupID)}
for len(queue) > 0 && len(files) < limit {
current := queue[0]
queue = queue[1:]
if _, ok := visitedGroups[current]; ok {
continue
}
visitedGroups[current] = struct{}{}
cursor := ""
for len(files) < limit {
result, err := r.Apps.ListReferences(ctx, workspaceID, appID, workspacebiz.AppReferenceListInput{
ParentGroupID: current,
FilterText: strings.TrimSpace(query),
Limit: pageLimit,
Cursor: cursor,
Kinds: []workspacebiz.AppReferenceKind{workspacebiz.AppReferenceKindFile},
})
if err != nil {
return nil, err
}
collectItems:
for _, item := range result.Items {
switch item.AppReferenceListItemType() {
case workspacebiz.AppReferenceListItemTypeGroup:
if group, ok := item.(workspacebiz.AppReferenceGroup); ok {
queue = append(queue, group.ID)
}
case workspacebiz.AppReferenceListItemTypeReference:
ref, ok := item.(workspacebiz.AppReferenceListReferenceItem)
if !ok {
continue
}
file, ok := ref.Reference.(workspacebiz.AppFileReference)
if !ok {
continue
}
if _, dup := seenFiles[file.Path]; dup {
continue
}
seenFiles[file.Path] = struct{}{}
files = append(files, File{
Path: file.Path,
DisplayName: file.DisplayName,
SizeBytes: derefInt64(file.SizeBytes),
MediaType: file.MimeType,
CreatedAtUnixMs: derefInt64(file.MtimeMs),
})
if len(files) >= limit {
break collectItems
}
}
}
cursor = strOrEmpty(result.NextCursor)
if cursor == "" {
break
}
}
}
return files, nil
}
// collectTaskFiles resolves task artifacts through the in-process IssueManagerService:
// a specific issue (groupId) → its latest outputs; whole topic (id, no groupId) → search.
func (r Resolver) collectTaskFiles(ctx context.Context, workspaceID, topicID, issueID, query string, limit int) ([]File, error) {
if r.Issues == nil {
return nil, ErrInvalidSource
}
files := make([]File, 0, limit)
if trimmedIssueID := strings.TrimSpace(issueID); trimmedIssueID != "" {
detail, err := r.Issues.GetIssueDetail(ctx, workspaceID, trimmedIssueID)
if err != nil {
return nil, err
}
needle := strings.ToLower(strings.TrimSpace(query))
for _, out := range detail.LatestOutputs {
if needle != "" && !strings.Contains(strings.ToLower(out.DisplayName), needle) {
continue
}
files = append(files, outputToFile(out))
if len(files) >= limit {
break
}
}
return files, nil
}
hits, err := r.Issues.SearchIssueOutputs(ctx, workspaceissues.RunOutputSearchParams{
WorkspaceID: workspaceID,
TopicID: strings.TrimSpace(topicID),
Query: strings.TrimSpace(query),
Limit: limit,
})
if err != nil {
return nil, err
}
for _, hit := range hits {
files = append(files, outputToFile(hit.Output))
if len(files) >= limit {
break
}
}
return files, nil
}
func outputToFile(out workspaceissues.RunOutput) File {
return File{
Path: out.Path,
DisplayName: out.DisplayName,
SizeBytes: out.SizeBytes,
MediaType: out.MediaType,
CreatedAtUnixMs: out.CreatedAtUnixMS,
}
}
func derefInt64(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func strOrEmpty(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}