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
345 lines
12 KiB
Go
345 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
|
agentproviderbiz "github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
|
|
)
|
|
|
|
// ExternalImportValidProjectPaths returns the canonical paths of the selected
|
|
// projects that contain at least one valid importable session, without importing
|
|
// anything. The register-only import path uses it to avoid surfacing empty
|
|
// projects. Returned paths are canonical (see canonicalExistingDir), matching
|
|
// ImportExternalSessions.ProjectPaths so callers can register them directly.
|
|
func (*Service) ExternalImportValidProjectPaths(ctx context.Context, input ExternalImportInput) ([]string, error) {
|
|
selections := normalizeExternalImportSelections(input.Projects)
|
|
if len(selections) == 0 {
|
|
return nil, nil
|
|
}
|
|
data, err := scanExternalAgentSessions(
|
|
ctx,
|
|
providersFromExternalImportSelections(selections),
|
|
-1,
|
|
input.ArchivePath,
|
|
input.ArchiveKind,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
valid := map[string]int64{}
|
|
for _, session := range data.sessions {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
if session.NoProject {
|
|
continue
|
|
}
|
|
if projectPath, ok := matchingExternalImportProject(session, selections); ok {
|
|
if session.UpdatedAtUnixMS > valid[projectPath] {
|
|
valid[projectPath] = session.UpdatedAtUnixMS
|
|
}
|
|
}
|
|
}
|
|
return sortedProjectPathsByLatest(valid), nil
|
|
}
|
|
|
|
func projectFromExternalSession(session externalImportedSession) (ExternalImportProject, bool) {
|
|
projectPath, ok := externalSessionProjectPath(session)
|
|
if !ok {
|
|
return ExternalImportProject{}, false
|
|
}
|
|
return ExternalImportProject{
|
|
Path: projectPath,
|
|
Label: filepath.Base(projectPath),
|
|
Providers: []string{session.Provider},
|
|
SessionCount: 1,
|
|
MessageCount: len(session.Messages),
|
|
LastUpdatedAtUnixMS: session.UpdatedAtUnixMS,
|
|
}, true
|
|
}
|
|
|
|
func externalSessionProjectPath(session externalImportedSession) (string, bool) {
|
|
// session.Cwd has already been resolved by resolveExternalImportSessionCwd
|
|
// (see external_import_parse.go) — canonicalized when the directory still
|
|
// exists, or a best-effort cleaned absolute path when it no longer does (a
|
|
// deleted worktree/temp dir must still be countable and importable, just
|
|
// without git-root-based project grouping). Re-requiring existence here
|
|
// would silently drop those sessions from the scan a second time.
|
|
cwd := filepath.Clean(strings.TrimSpace(session.Cwd))
|
|
if cwd == "" || cwd == "." {
|
|
return "", false
|
|
}
|
|
if session.NoProject {
|
|
// Every "no project selected" session (whether it literally ran in the
|
|
// user's home directory, or in a provider-owned scratch workspace such
|
|
// as Codex's ~/Documents/Codex/<slug>) collapses onto one consistent
|
|
// bucket instead of surfacing its own machine-generated scratch
|
|
// directory name as if it were a real project. Using the raw cwd here
|
|
// previously surfaced synthetic slugs (e.g. "2026-04-24-gh") as bogus
|
|
// project labels — the source of reports that imported project folder
|
|
// names looked garbled and didn't match any folder the user chose.
|
|
if home, ok := externalImportNoProjectBucketPath(); ok {
|
|
return home, true
|
|
}
|
|
return cwd, true
|
|
}
|
|
if gitRoot, ok := nearestExternalImportGitRoot(cwd); ok {
|
|
return gitRoot, true
|
|
}
|
|
return cwd, true
|
|
}
|
|
|
|
// externalImportNoProjectBucketPath resolves the canonical path used to group
|
|
// every no-project-selected session together, so the scan/import result
|
|
// treats them as one consistent "no project" bucket rather than one bogus
|
|
// per-session "project" per scratch directory.
|
|
func externalImportNoProjectBucketPath() (string, bool) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return canonicalExistingDir(home)
|
|
}
|
|
|
|
func nearestExternalImportGitRoot(cwd string) (string, bool) {
|
|
current := filepath.Clean(cwd)
|
|
for current != "" {
|
|
gitPath := filepath.Join(current, ".git")
|
|
if info, err := os.Lstat(gitPath); err == nil {
|
|
if !info.IsDir() {
|
|
// A `.git` *file* (rather than directory) at the nearest git
|
|
// root means current is a linked worktree, not the main
|
|
// checkout — resolve back to the main checkout so callers
|
|
// never treat an ephemeral per-task worktree as its own
|
|
// independent project. Fall back to current, unresolved, if
|
|
// that fails for any reason (e.g. malformed metadata).
|
|
if mainRoot, ok := resolveGitWorktreeMainRoot(gitPath); ok {
|
|
return mainRoot, true
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// resolveExternalImportWorktreeCwd walks up from cwd looking for the nearest
|
|
// git root — a directory containing a `.git` entry, whether a real
|
|
// directory (a normal/main checkout) or a `.git` *file* (the pointer git
|
|
// leaves at the root of a linked worktree created via `git worktree add`).
|
|
// When the nearest root turns out to be a linked worktree, it resolves back
|
|
// to the *main* checkout's working-tree root and maps cwd onto the
|
|
// equivalent path under that root, preserving any subdirectory depth.
|
|
//
|
|
// This matters because a coding agent (Codex, in particular) commonly runs
|
|
// each task inside its own throwaway worktree, e.g.
|
|
// ~/.codex/worktrees/8db5/tsh. That directory has its own `.git` file, so
|
|
// naively it looks like its own independent git root/project — but it is
|
|
// not the directory the user actually registered as their "tsh" project;
|
|
// the main checkout is. Without this resolution, every session imported
|
|
// from such a worktree gets grouped under (and, if registered, creates a
|
|
// brand-new project card for) the ephemeral worktree path instead of lining
|
|
// up with the user's real, already-registered project — leaving the real
|
|
// project empty and the imported sessions stranded in the no-project
|
|
// bucket.
|
|
//
|
|
// Returns ok=false whenever there's nothing to resolve — no git root found,
|
|
// the root is a normal checkout, or the worktree's metadata can't be read
|
|
// for any reason (malformed, main checkout since removed) — so callers can
|
|
// safely keep using cwd unchanged.
|
|
func resolveExternalImportWorktreeCwd(cwd string) (string, bool) {
|
|
current := filepath.Clean(cwd)
|
|
for current != "" {
|
|
gitPath := filepath.Join(current, ".git")
|
|
info, err := os.Lstat(gitPath)
|
|
if err != nil {
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return "", false
|
|
}
|
|
current = parent
|
|
continue
|
|
}
|
|
if info.IsDir() {
|
|
return "", false
|
|
}
|
|
mainRoot, ok := resolveGitWorktreeMainRoot(gitPath)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
rel, err := filepath.Rel(current, filepath.Clean(cwd))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return filepath.Clean(filepath.Join(mainRoot, rel)), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// resolveGitWorktreeMainRoot resolves a linked worktree's `.git` pointer
|
|
// file (gitFilePath) back to the main checkout's working-tree root. It
|
|
// follows the same two-step lookup git itself uses: the pointer file's
|
|
// "gitdir: <path>" line names the worktree's private metadata directory
|
|
// (<main>/.git/worktrees/<name>), and that directory's `commondir` file
|
|
// names the shared .git directory it's relative to (normally "../.."). The
|
|
// main checkout's root is that shared directory's parent.
|
|
func resolveGitWorktreeMainRoot(gitFilePath string) (string, bool) {
|
|
data, err := os.ReadFile(gitFilePath)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
const prefix = "gitdir:"
|
|
line := strings.TrimSpace(string(data))
|
|
if !strings.HasPrefix(line, prefix) {
|
|
return "", false
|
|
}
|
|
worktreeGitDir := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
if worktreeGitDir == "" {
|
|
return "", false
|
|
}
|
|
if !filepath.IsAbs(worktreeGitDir) {
|
|
worktreeGitDir = filepath.Join(filepath.Dir(gitFilePath), worktreeGitDir)
|
|
}
|
|
commonDirRaw, err := os.ReadFile(filepath.Join(worktreeGitDir, "commondir"))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
commonDir := strings.TrimSpace(string(commonDirRaw))
|
|
if commonDir == "" {
|
|
return "", false
|
|
}
|
|
if !filepath.IsAbs(commonDir) {
|
|
commonDir = filepath.Join(worktreeGitDir, commonDir)
|
|
}
|
|
return canonicalExistingDir(filepath.Dir(filepath.Clean(commonDir)))
|
|
}
|
|
|
|
func externalImportSessionSummary(session externalImportedSession, projectPath string) ExternalImportSession {
|
|
return ExternalImportSession{
|
|
ID: externalImportedSessionID(session.Provider, session.ProviderSessionID),
|
|
ProjectPath: projectPath,
|
|
Provider: session.Provider,
|
|
SourcePath: session.SourcePath,
|
|
Title: session.Title,
|
|
MessageCount: len(session.Messages),
|
|
LastUpdatedAtUnixMS: session.UpdatedAtUnixMS,
|
|
}
|
|
}
|
|
|
|
func upsertExternalImportProject(projects map[string]*ExternalImportProject, next ExternalImportProject, provider string) {
|
|
identityKey := agentactivitybiz.RailSectionKeyForProject(next.Path)
|
|
project, ok := projects[identityKey]
|
|
if !ok {
|
|
projects[identityKey] = &next
|
|
return
|
|
}
|
|
project.SessionCount += next.SessionCount
|
|
project.MessageCount += next.MessageCount
|
|
if next.LastUpdatedAtUnixMS > project.LastUpdatedAtUnixMS {
|
|
project.LastUpdatedAtUnixMS = next.LastUpdatedAtUnixMS
|
|
}
|
|
for _, existingProvider := range project.Providers {
|
|
if existingProvider == provider {
|
|
return
|
|
}
|
|
}
|
|
project.Providers = append(project.Providers, provider)
|
|
}
|
|
|
|
func matchingExternalImportProject(session externalImportedSession, selections []ExternalImportProjectSelection) (string, bool) {
|
|
bestPath := ""
|
|
for _, selection := range selections {
|
|
if !externalProviderSelected(session.Provider, selection.Providers) {
|
|
continue
|
|
}
|
|
if len(selection.SessionIDs) > 0 && !externalSessionSelected(session, selection.SessionIDs) {
|
|
continue
|
|
}
|
|
if externalProjectPathContains(selection.Path, session.Cwd) {
|
|
selectionPath := agentactivitybiz.NormalizeProjectPath(selection.Path)
|
|
if agentactivitybiz.AreProjectPathsEqual(selectionPath, session.Cwd) {
|
|
return selection.Path, true
|
|
}
|
|
if bestPath == "" || len(selectionPath) > len(agentactivitybiz.NormalizeProjectPath(bestPath)) {
|
|
bestPath = selection.Path
|
|
}
|
|
}
|
|
}
|
|
return bestPath, bestPath != ""
|
|
}
|
|
|
|
func externalSessionSelected(session externalImportedSession, sessionIDs []string) bool {
|
|
sessionID := externalImportedSessionID(session.Provider, session.ProviderSessionID)
|
|
for _, candidate := range sessionIDs {
|
|
if strings.TrimSpace(candidate) == sessionID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func externalProviderSelected(provider string, providers []string) bool {
|
|
normalized := agentproviderbiz.Normalize(provider)
|
|
if normalized == "" {
|
|
// Import-only archive providers (e.g. chatgpt) are not runnable
|
|
// registry providers, so normalize them the same way the selection
|
|
// providers are normalized below.
|
|
normalized = normalizeExternalArchiveImportProvider(provider)
|
|
}
|
|
for _, candidate := range normalizeExternalImportProviders(providers) {
|
|
if candidate == normalized {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func externalProjectPathContains(parent string, child string) bool {
|
|
return agentactivitybiz.IsProjectPathWithin(parent, child)
|
|
}
|
|
|
|
func canonicalExistingDir(path string) (string, bool) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return "", false
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil || !info.IsDir() {
|
|
return "", false
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = resolved
|
|
}
|
|
return filepath.Clean(abs), true
|
|
}
|
|
|
|
func sortedProjectPathsByLatest(values map[string]int64) []string {
|
|
out := make([]string, 0, len(values))
|
|
for value := range values {
|
|
out = append(out, value)
|
|
}
|
|
sort.SliceStable(out, func(left, right int) bool {
|
|
leftUpdatedAt := values[out[left]]
|
|
rightUpdatedAt := values[out[right]]
|
|
if leftUpdatedAt == rightUpdatedAt {
|
|
return out[left] < out[right]
|
|
}
|
|
return leftUpdatedAt > rightUpdatedAt
|
|
})
|
|
return out
|
|
}
|