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

717 lines
24 KiB
Go

package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
agentproviderbiz "github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
)
func (*Service) ScanExternalImports(ctx context.Context, input ExternalImportScanInput) (ExternalImportScanResult, error) {
data, err := scanExternalAgentSessions(ctx, normalizeExternalImportProviders(input.Providers), input.Days, input.ArchivePath, input.ArchiveKind)
if err != nil {
return ExternalImportScanResult{}, err
}
return data.result, nil
}
func (s *Service) ImportExternalSessions(ctx context.Context, workspaceID string, input ExternalImportInput) (ExternalImportResult, error) {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" || len(input.Projects) == 0 {
return ExternalImportResult{}, ErrInvalidArgument
}
if s == nil || s.ExternalImportStore == nil {
return ExternalImportResult{}, errors.New("external agent import store is unavailable")
}
selections := normalizeExternalImportSelections(input.Projects)
if len(selections) == 0 {
return ExternalImportResult{}, ErrInvalidArgument
}
// Scan all available history when importing: the request already filters by
// explicit project paths and session ids, and the picker may surface
// conversations older than the default 30-day window.
data, err := scanExternalAgentSessions(
ctx,
providersFromExternalImportSelections(selections),
-1,
input.ArchivePath,
input.ArchiveKind,
)
if err != nil {
return ExternalImportResult{}, err
}
result := ExternalImportResult{
SkippedSessions: data.result.SkippedSessions,
Errors: append([]ExternalImportError(nil), data.result.Errors...),
}
importedProjectPaths := map[string]struct{}{}
validProjectPaths := map[string]int64{}
for _, session := range data.sessions {
if ctx.Err() != nil {
return result, ctx.Err()
}
projectPath, selected := matchingExternalImportProject(session, selections)
if !selected {
continue
}
importedMessages, imported, err := s.importExternalSession(ctx, workspaceID, session, projectPath)
if err != nil {
result.Errors = append(result.Errors, ExternalImportError{
Provider: session.Provider,
SourcePath: session.SourcePath,
Message: err.Error(),
})
continue
}
// Only mark projectPath as a candidate for ProjectPaths once its session
// has actually landed in the store without error. Marking it here
// unconditionally (before knowing whether importExternalSession
// succeeded) let a project whose only session failed to import (a
// transient store error, a write timeout, etc.) still come back in
// ProjectPaths — and registerExternalImportUserProjects registers
// whatever ProjectPaths contains, so that project would surface as a
// folder card with "No chats yet" and no way to tell it apart from a
// project that imported cleanly.
if !session.NoProject && session.UpdatedAtUnixMS > validProjectPaths[projectPath] {
validProjectPaths[projectPath] = session.UpdatedAtUnixMS
}
if imported {
result.ImportedSessions++
}
if importedMessages > 0 {
result.ImportedMessages += importedMessages
if !session.NoProject {
importedProjectPaths[projectPath] = struct{}{}
}
}
}
result.ImportedProjects = len(importedProjectPaths)
result.ProjectPaths = sortedProjectPathsByLatest(validProjectPaths)
return result, nil
}
func normalizeExternalImportProviders(input []string) []string {
if len(input) == 0 {
out := make([]string, 0)
for _, descriptor := range providerregistry.Migrated() {
if descriptor.ExternalImport.Enabled {
out = append(out, descriptor.Identity.ID)
}
}
return out
}
seen := map[string]struct{}{}
out := make([]string, 0, len(input))
for _, provider := range input {
normalized := agentproviderbiz.Normalize(provider)
if normalized == "" {
// Import-only archive providers (e.g. the ChatGPT data export) are
// deliberately not runnable registry providers, so they never
// resolve through providerregistry. Preserve their identity here so
// import selections that reference them survive normalization.
normalized = normalizeExternalArchiveImportProvider(provider)
if normalized == "" {
continue
}
} else {
descriptor, ok := providerregistry.Find(normalized)
if !ok || !descriptor.ExternalImport.Enabled {
continue
}
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out
}
// normalizeExternalArchiveImportProvider recognizes import-only archive
// providers that have no runnable providerregistry descriptor. Returns the
// canonical identity, or "" if the value is not a known archive-only provider.
func normalizeExternalArchiveImportProvider(provider string) string {
switch strings.TrimSpace(strings.ToLower(provider)) {
case chatgptExportProvider:
return chatgptExportProvider
default:
return ""
}
}
func normalizeExternalImportSelections(input []ExternalImportProjectSelection) []ExternalImportProjectSelection {
out := make([]ExternalImportProjectSelection, 0, len(input))
for _, selection := range input {
path, ok := canonicalExistingDir(selection.Path)
if !ok {
continue
}
out = append(out, ExternalImportProjectSelection{
Path: path,
Providers: normalizeExternalImportProviders(selection.Providers),
SessionIDs: normalizeExternalImportSessionIDs(selection.SessionIDs),
})
}
return out
}
func normalizeExternalImportSessionIDs(input []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(input))
for _, id := range input {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func providersFromExternalImportSelections(selections []ExternalImportProjectSelection) []string {
seen := map[string]struct{}{}
out := make([]string, 0, 2)
for _, selection := range selections {
for _, provider := range normalizeExternalImportProviders(selection.Providers) {
if _, ok := seen[provider]; ok {
continue
}
seen[provider] = struct{}{}
out = append(out, provider)
}
}
return out
}
// externalImportedSessionSettings carries the provider-reported model/effort
// forward into the imported session's composer settings so continuing the
// conversation in Tutti reuses the same model configuration the user's local
// CLI had, instead of silently falling back to workspace defaults.
func externalImportedSessionSettings(session externalImportedSession) map[string]any {
reasoningEffort := strings.TrimSpace(session.ReasoningEffort)
if !composerProviderUsesModelReasoningCatalog(session.Provider) {
reasoningEffort = normalizeReasoningEffortForProvider(session.Provider, reasoningEffort)
}
settings := ComposerSettings{
Model: strings.TrimSpace(session.Model),
ReasoningEffort: reasoningEffort,
}
if composerSettingsIsEmpty(settings) {
return nil
}
return composerSettingsToPayload(settings)
}
func externalImportAgentTargetID(provider string) string {
normalized := agentproviderbiz.Normalize(provider)
if descriptor, ok := providerregistry.Find(normalized); ok {
return descriptor.Target.ID
}
return ""
}
func scanExternalAgentSessions(ctx context.Context, providers []string, days int, archivePath string, archiveKind string) (externalScanData, error) {
if strings.TrimSpace(archivePath) != "" {
archiveKind = normalizeExternalImportArchiveKind(archiveKind)
// The Claude archive scan still requires the claude-code provider to be
// selected (its historical gate); ChatGPT archives are a standalone
// import-only source and carry no such provider requirement.
if archiveKind == ExternalImportArchiveKindClaude &&
len(providers) > 0 && !providersIncludeArchiveImportParser(providers) {
return externalScanData{}, fmt.Errorf(
"%w: a Claude export archive scan requires the claude-code provider",
ErrInvalidArgument,
)
}
// An export archive is a complete snapshot, so no implicit 30-day
// window applies; only an explicit positive day range narrows it.
cutoffUnixMS := int64(0)
if days > 0 {
cutoffUnixMS = externalScanCutoffUnixMS(days)
}
switch archiveKind {
case ExternalImportArchiveKindChatGPT:
return scanChatGPTExportArchive(ctx, archivePath, cutoffUnixMS)
default:
return scanClaudeExportArchive(ctx, archivePath, cutoffUnixMS)
}
}
data := externalScanData{}
projects := map[string]*ExternalImportProject{}
cutoffUnixMS := externalScanCutoffUnixMS(days)
for _, provider := range normalizeExternalImportProviders(providers) {
if ctx.Err() != nil {
break
}
sessions, summary, errors := scanExternalProviderSessions(provider, cutoffUnixMS)
data.result.Providers = append(data.result.Providers, summary)
data.result.Errors = append(data.result.Errors, errors...)
for _, session := range sessions {
project, ok := projectFromExternalSession(session)
if !ok {
data.result.SkippedSessions++
continue
}
data.sessions = append(data.sessions, session)
data.result.ScannedSessions++
data.result.ScannedMessages += len(session.Messages)
data.result.Sessions = append(data.result.Sessions, externalImportSessionSummary(session, project.Path))
upsertExternalImportProject(projects, project, session.Provider)
}
}
for _, project := range projects {
sort.Strings(project.Providers)
data.result.Projects = append(data.result.Projects, *project)
}
sort.SliceStable(data.result.Projects, func(left, right int) bool {
if data.result.Projects[left].LastUpdatedAtUnixMS == data.result.Projects[right].LastUpdatedAtUnixMS {
return data.result.Projects[left].Path < data.result.Projects[right].Path
}
return data.result.Projects[left].LastUpdatedAtUnixMS > data.result.Projects[right].LastUpdatedAtUnixMS
})
sort.SliceStable(data.result.Sessions, func(left, right int) bool {
if data.result.Sessions[left].LastUpdatedAtUnixMS == data.result.Sessions[right].LastUpdatedAtUnixMS {
return data.result.Sessions[left].ID < data.result.Sessions[right].ID
}
return data.result.Sessions[left].LastUpdatedAtUnixMS > data.result.Sessions[right].LastUpdatedAtUnixMS
})
// The provider loop breaks on cancellation, so surface it instead of
// letting a partial scan pass as a complete result.
if err := ctx.Err(); err != nil {
return externalScanData{}, err
}
return data, nil
}
// normalizeExternalImportArchiveKind resolves a request archive kind, defaulting
// an empty value to Claude for backward compatibility with clients that only
// send archivePath.
func normalizeExternalImportArchiveKind(kind string) string {
switch strings.TrimSpace(strings.ToLower(kind)) {
case ExternalImportArchiveKindChatGPT:
return ExternalImportArchiveKindChatGPT
case "", ExternalImportArchiveKindClaude:
return ExternalImportArchiveKindClaude
default:
// Unknown kinds fall back to Claude rather than silently importing
// nothing; the API layer validates the enum before reaching here.
return ExternalImportArchiveKindClaude
}
}
func providersIncludeArchiveImportParser(providers []string) bool {
for _, provider := range providers {
descriptor, ok := providerregistry.Find(provider)
if ok && descriptor.ExternalImport.ParserKind == providerregistry.ExternalImportParserKindClaudeJSONL {
return true
}
}
return false
}
// externalScanCutoffUnixMS resolves the "updated since" cutoff for a scan
// window expressed in days. 0 keeps the historical 30-day default; a negative
// value disables the cutoff so all available history is scanned (used by the
// import path, which filters by explicit selections instead).
func externalScanCutoffUnixMS(days int) int64 {
if days < 0 {
return 0
}
if days == 0 {
days = 30
}
return time.Now().Add(-time.Duration(days) * 24 * time.Hour).UnixMilli()
}
func scanExternalProviderSessions(provider string, cutoffUnixMS int64) ([]externalImportedSession, ExternalImportProvider, []ExternalImportError) {
descriptor, ok := providerregistry.Find(provider)
if !ok || !descriptor.ExternalImport.Enabled {
return nil, ExternalImportProvider{Provider: provider}, nil
}
root := externalProviderRoot(descriptor.ExternalImport)
summary := ExternalImportProvider{Provider: provider, Root: root}
if root == "" {
return nil, summary, nil
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return nil, summary, nil
}
summary.Available = true
files, err := externalProviderJSONLFiles(descriptor.ExternalImport, root)
if err != nil {
summary.Error = err.Error()
return nil, summary, []ExternalImportError{{Provider: provider, Message: err.Error()}}
}
// Codex stores the generated conversation title in its app-server SQLite
// state DB rather than in the rollout transcript, so resolve it up front and
// let it override the message-derived title.
var importedTitles map[string]string
if descriptor.ExternalImport.TitleCatalogKind == providerregistry.ExternalImportTitleCatalogKindCodexSQLite {
importedTitles = codexThreadTitles(root)
}
sessions := make([]externalImportedSession, 0, len(files))
errors := make([]ExternalImportError, 0)
for _, file := range files {
session, ok, err := parseExternalProviderJSONL(descriptor, file)
if err != nil {
errors = append(errors, ExternalImportError{Provider: provider, SourcePath: file, Message: err.Error()})
continue
}
if !ok {
continue
}
if session.UpdatedAtUnixMS < cutoffUnixMS {
continue
}
if title := strings.TrimSpace(importedTitles[session.ProviderSessionID]); title != "" {
session.Title = truncateExternalTitle(title)
}
sessions = append(sessions, session)
summary.SessionCount++
summary.MessageCount += len(session.Messages)
}
return sessions, summary, errors
}
func externalProviderRoot(descriptor providerregistry.ExternalImportDescriptor) string {
home, _ := os.UserHomeDir()
if descriptor.RootEnvVar != "" {
if root := strings.TrimSpace(os.Getenv(descriptor.RootEnvVar)); root != "" {
return root
}
}
if home != "" && strings.HasPrefix(descriptor.DefaultRoot, "~/") {
return filepath.Join(home, strings.TrimPrefix(descriptor.DefaultRoot, "~/"))
}
return strings.TrimSpace(descriptor.DefaultRoot)
}
func externalProviderJSONLFiles(descriptor providerregistry.ExternalImportDescriptor, root string) ([]string, error) {
roots := make([]string, 0, len(descriptor.ScanDirectories))
for _, directory := range descriptor.ScanDirectories {
roots = append(roots, filepath.Join(root, directory))
}
files := make([]string, 0)
for _, scanRoot := range roots {
if info, err := os.Stat(scanRoot); err != nil || !info.IsDir() {
continue
}
err := filepath.WalkDir(scanRoot, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
for _, prefix := range descriptor.SkipDirectoryPrefixes {
if strings.HasPrefix(entry.Name(), prefix) {
return filepath.SkipDir
}
}
return nil
}
if strings.EqualFold(filepath.Ext(path), ".jsonl") {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(files)
return files, nil
}
func parseExternalProviderJSONL(descriptor providerregistry.ProviderDescriptor, path string) (externalImportedSession, bool, error) {
file, err := os.Open(path)
if err != nil {
return externalImportedSession{}, false, err
}
defer file.Close()
var session externalImportedSession
var ok bool
switch descriptor.ExternalImport.ParserKind {
case providerregistry.ExternalImportParserKindCodexJSONL:
session, ok, err = parseCodexJSONL(path, file)
case providerregistry.ExternalImportParserKindClaudeJSONL:
session, ok, err = parseClaudeCodeJSONL(path, file)
default:
return externalImportedSession{}, false, fmt.Errorf("external import parser %q is unsupported", descriptor.ExternalImport.ParserKind)
}
if ok {
session.Provider = descriptor.Identity.ID
}
return session, ok, err
}
func (s *Service) importExternalSession(
ctx context.Context,
workspaceID string,
session externalImportedSession,
projectPath string,
) (int, bool, error) {
agentSessionID := externalImportedSessionID(session.Provider, session.ProviderSessionID)
existingTurnIDs, sessionExists, err := s.existingExternalImportMessageTurnIDs(ctx, workspaceID, agentSessionID)
if err != nil {
return 0, false, err
}
updates := make([]agentactivitybiz.MessageUpdate, 0, len(session.Messages))
currentTurnID := ""
for i, message := range session.Messages {
messageID := externalImportedMessageIDForMessage(session.Provider, session.ProviderSessionID, message, i)
if message.Role == "user" && message.Kind == "text" {
currentTurnID = externalImportedTurnID(messageID)
}
existingTurnID, exists := existingTurnIDs[messageID]
if exists && (currentTurnID == "" || existingTurnID == currentTurnID) {
continue
}
updates = append(updates, agentactivitybiz.MessageUpdate{
MessageID: messageID,
TurnID: currentTurnID,
Role: message.Role,
Kind: message.Kind,
Status: message.Status,
Payload: externalImportedMessagePayload(message),
OccurredAtUnixMS: message.OccurredAtUnixMS,
StartedAtUnixMS: message.StartedAtUnixMS,
CompletedAtUnixMS: message.CompletedAtUnixMS,
})
}
runtimeContext := map[string]any{
"visible": true,
"imported": true,
"externalImportNoProject": session.NoProject,
"externalSourcePath": session.SourcePath,
}
if session.ResumeSupported != nil {
runtimeContext["externalImportResumeSupported"] = *session.ResumeSupported
}
if _, err := s.ExternalImportStore.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
Origin: WorkspaceAgentSessionOriginImported,
AgentTargetID: externalImportAgentTargetID(session.Provider),
Provider: session.Provider,
ProviderSessionID: session.ProviderSessionID,
Model: session.Model,
Settings: externalImportedSessionSettings(session),
RuntimeContext: runtimeContext,
Cwd: session.Cwd,
ImportProjectPath: projectPath,
Title: session.Title,
Status: "completed",
CurrentPhase: "completed",
OccurredAtUnixMS: session.UpdatedAtUnixMS,
StartedAtUnixMS: session.StartedAtUnixMS,
EndedAtUnixMS: session.UpdatedAtUnixMS,
}); err != nil {
return 0, false, err
}
if len(updates) == 0 && sessionExists {
return 0, false, nil
}
importedMessages := 0
for start := 0; start < len(updates); start += 200 {
end := start + 200
if end > len(updates) {
end = len(updates)
}
report, err := s.ExternalImportStore.ReportSessionMessages(ctx, agentactivitybiz.SessionMessageReport{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
Origin: WorkspaceAgentSessionOriginImported,
Provider: session.Provider,
HistoricalImport: true,
Messages: updates[start:end],
})
if err != nil {
return importedMessages, true, err
}
importedMessages += report.AcceptedCount
}
return importedMessages, true, nil
}
func (s *Service) existingExternalImportMessageTurnIDs(ctx context.Context, workspaceID string, agentSessionID string) (map[string]string, bool, error) {
turnIDs := map[string]string{}
if s == nil || s.ExternalImportStore == nil {
return turnIDs, false, nil
}
if _, ok, err := s.ExternalImportStore.GetSession(ctx, workspaceID, agentSessionID); err != nil || !ok {
return turnIDs, ok, err
}
var after uint64
for {
page, ok, err := s.ExternalImportStore.ListSessionMessages(ctx, agentactivitybiz.ListSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
AfterVersion: after,
Limit: 1000,
Order: agentactivitybiz.MessageOrderAsc,
})
if err != nil || !ok {
return turnIDs, true, err
}
if len(page.Messages) == 0 {
return turnIDs, true, nil
}
for _, message := range page.Messages {
turnIDs[strings.TrimSpace(message.MessageID)] = strings.TrimSpace(message.TurnID)
if message.Version > after {
after = message.Version
}
}
if !page.HasMore {
return turnIDs, true, nil
}
}
}
func externalImportedSessionID(provider string, providerSessionID string) string {
return "imported-" + externalImportProviderSlug(provider) + "-" + externalStableHash(providerSessionID)[:24]
}
// externalImportProviderSlug resolves a stable, filesystem-safe provider slug
// for imported session identities. Registered runnable providers normalize to
// their canonical id; import-only sources (e.g. the ChatGPT data export, which
// is deliberately not a runnable registry provider) fall back to a sanitized
// form of their raw identity so their session ids stay readable and unique
// instead of collapsing to an empty segment.
func externalImportProviderSlug(provider string) string {
if normalized := agentproviderbiz.Normalize(provider); normalized != "" {
return normalized
}
var builder strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(provider)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
builder.WriteRune(r)
default:
builder.WriteRune('-')
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
return "import"
}
return slug
}
func externalImportedMessageID(provider string, providerSessionID string, rawID string, index int) string {
return "imported-" + externalStableHash(provider + "\x00" + providerSessionID + "\x00" + rawID + "\x00" + strconv.Itoa(index))[:32]
}
func externalImportedMessageIDForMessage(provider string, providerSessionID string, message externalImportedMessage, index int) string {
if seed := strings.TrimSpace(message.MessageIDSeed); seed != "" {
return "imported-" + externalStableHash(provider + "\x00" + providerSessionID + "\x00" + seed)[:32]
}
return externalImportedMessageID(provider, providerSessionID, message.RawID, index)
}
func externalImportedTurnID(userMessageID string) string {
const messagePrefix = "imported-"
userMessageID = strings.TrimSpace(userMessageID)
if !strings.HasPrefix(userMessageID, messagePrefix) || len(userMessageID) == len(messagePrefix) {
return ""
}
return "imported-turn-" + strings.TrimPrefix(userMessageID, messagePrefix)
}
func externalImportedMessagePayload(message externalImportedMessage) map[string]any {
payload := clonePayload(message.Payload)
if payload == nil {
payload = map[string]any{}
}
if strings.TrimSpace(message.Kind) == "text" {
payload["text"] = message.Text
}
return payload
}
func externalStableHash(input string) string {
sum := sha256.Sum256([]byte(input))
return hex.EncodeToString(sum[:])
}
func externalSessionTitle(messages []externalImportedMessage) string {
for _, message := range messages {
if message.Role == "user" {
return truncateExternalTitle(message.Text)
}
}
return truncateExternalTitle(messages[0].Text)
}
func truncateExternalTitle(input string) string {
input = strings.Join(strings.Fields(input), " ")
const maxTitleRunes = 80
runes := []rune(input)
if len(runes) <= maxTitleRunes {
return input
}
return strings.TrimSpace(string(runes[:maxTitleRunes]))
}
func firstExternalMessageUnixMS(messages []externalImportedMessage) int64 {
for _, message := range messages {
if message.OccurredAtUnixMS > 0 {
return message.OccurredAtUnixMS
}
}
return time.Now().UnixMilli()
}
func lastExternalMessageUnixMS(messages []externalImportedMessage) int64 {
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].OccurredAtUnixMS > 0 {
return messages[i].OccurredAtUnixMS
}
}
return firstExternalMessageUnixMS(messages)
}
func normalizeExternalMessageRole(role string) string {
switch strings.TrimSpace(strings.ToLower(role)) {
case "user", "assistant", "tool":
return strings.TrimSpace(strings.ToLower(role))
default:
return ""
}
}
func normalizeExternalMessageKind(kind string) string {
switch strings.TrimSpace(strings.ToLower(kind)) {
case "tool_call":
return "tool_call"
case "reasoning":
return "reasoning"
default:
return "text"
}
}
func normalizeExternalMessageStatus(status string) string {
switch strings.TrimSpace(strings.ToLower(status)) {
case "running", "completed", "failed", "canceled", "waiting":
return strings.TrimSpace(strings.ToLower(status))
default:
return "completed"
}
}