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

522 lines
16 KiB
Go

package appcli
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
appclicore "github.com/tutti-os/tutti/packages/appcli/core"
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
cliservice "github.com/tutti-os/tutti/services/tuttid/service/cli"
)
const invokeSchemaVersion = appclicore.InvokeSchemaVersion
var reservedScopes = map[string]struct{}{
"agent": {},
"help": {},
"issue": {},
"plan": {},
"status": {},
}
type WorkspaceCatalog interface {
Startup(context.Context) (*workspacebiz.Summary, error)
Get(context.Context, string) (workspacebiz.Summary, error)
}
type RuntimeController interface {
EnsureAppRunningForCLI(context.Context, string, string) (string, error)
}
type Registry struct {
Workspaces WorkspaceCatalog
Runtime RuntimeController
HTTPClient *http.Client
mu sync.RWMutex
scopeSets map[string]*appclicore.ScopeSet
}
type Activation struct {
WorkspaceID string
AppPackage workspacebiz.AppPackage
BaseURL string
}
func NewRegistry(workspaces WorkspaceCatalog, runtime RuntimeController) *Registry {
return &Registry{Workspaces: workspaces, Runtime: runtime}
}
func (r *Registry) Activate(_ context.Context, activation Activation) workspacebiz.AppCLIState {
workspaceID := strings.TrimSpace(activation.WorkspaceID)
appPackage := activation.AppPackage
if appPackage.Manifest.CLI == nil {
r.Deactivate(workspaceID, appPackage.AppID)
return workspacebiz.AppCLIState{Status: workspacebiz.AppCLIStatusNone}
}
cliPath, err := CLIManifestPath(appPackage.PackageDir, appPackage.Manifest.CLI.Manifest)
if err != nil {
return r.setError(workspaceID, appPackage.AppID, "", "app_cli_manifest_path_invalid", err.Error())
}
manifest, err := ReadManifest(cliPath)
if err != nil {
return r.setError(workspaceID, appPackage.AppID, "", "app_cli_manifest_invalid", err.Error())
}
documentationFile, documentationPath, err := resolveDocumentation(appPackage.PackageDir, manifest)
if err != nil {
return r.setError(workspaceID, appPackage.AppID, manifest.Scope, errCode(err), err.Error())
}
iconURL := ""
if appIconURL := appPackage.IconDataURL(); appIconURL != nil {
iconURL = strings.TrimSpace(*appIconURL)
}
commands := appclicore.BuildCommands(manifest, appclicore.CommandBuildOptions{
AppID: appPackage.AppID,
AppName: appPackage.DisplayName(),
IconURL: iconURL,
AppDescription: appPackage.Description(),
DocumentationFile: documentationFile,
DocumentationPath: documentationPath,
})
r.mu.Lock()
defer r.mu.Unlock()
scopeSet := r.scopeSetLocked(workspaceID)
state := scopeSet.Upsert(appclicore.RegisteredApp{
AppID: appPackage.AppID,
AppName: appPackage.DisplayName(),
Scope: manifest.Scope,
BaseURL: strings.TrimRight(strings.TrimSpace(activation.BaseURL), "/"),
Commands: commands,
})
return serviceState(state)
}
func (r *Registry) Deactivate(workspaceID string, appID string) {
workspaceID = strings.TrimSpace(workspaceID)
appID = strings.TrimSpace(appID)
if workspaceID == "" || appID == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.scopeSets == nil {
return
}
if scopeSet := r.scopeSets[workspaceID]; scopeSet != nil {
scopeSet.Remove(appID)
if scopeSet.Empty() {
delete(r.scopeSets, workspaceID)
}
}
}
func (r *Registry) DeactivateApp(appID string) {
appID = strings.TrimSpace(appID)
if appID == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
for workspaceID, scopeSet := range r.scopeSets {
if scopeSet == nil {
continue
}
scopeSet.Remove(appID)
if scopeSet.Empty() {
delete(r.scopeSets, workspaceID)
}
}
}
func (r *Registry) Status(workspaceID string, app workspacebiz.WorkspaceApp) workspacebiz.AppCLIState {
if app.Installation == nil || app.Package.Manifest.CLI == nil {
return workspacebiz.AppCLIState{Status: workspacebiz.AppCLIStatusNone}
}
workspaceID = strings.TrimSpace(workspaceID)
r.mu.RLock()
defer r.mu.RUnlock()
if r.scopeSets != nil {
if scopeSet := r.scopeSets[workspaceID]; scopeSet != nil {
if state := serviceState(scopeSet.State(app.Package.AppID)); state.Status != "" {
return state
}
}
}
return workspacebiz.AppCLIState{Status: workspacebiz.AppCLIStatusPending}
}
func (r *Registry) Capabilities(ctx context.Context, invokeContext cliservice.InvokeContext) []cliservice.Capability {
workspaceID, err := r.resolveWorkspaceID(ctx, invokeContext.WorkspaceID)
if err != nil {
return []cliservice.Capability{}
}
r.mu.RLock()
scopeSet := r.scopeSet(workspaceID)
if scopeSet == nil {
r.mu.RUnlock()
return []cliservice.Capability{}
}
capabilities := scopeSet.Capabilities(appclicore.CapabilityListOptions{
IncludeIntegration: invokeContext.IncludeIntegrationCapabilities,
})
r.mu.RUnlock()
return serviceCapabilities(capabilities)
}
func (r *Registry) Invoke(ctx context.Context, request cliservice.InvokeRequest) (cliservice.CommandOutput, error) {
commandID := strings.TrimSpace(request.CommandID)
if commandID == "" {
return cliservice.CommandOutput{}, cliservice.ErrCommandNotFound
}
if strings.TrimSpace(request.Context.ParentCommandID) == commandID {
return cliservice.CommandOutput{}, fmt.Errorf("%w: recursive app cli command invocation", cliservice.ErrInvalidInput)
}
workspaceID, err := r.resolveWorkspaceID(ctx, request.Context.WorkspaceID)
if err != nil {
return cliservice.CommandOutput{}, err
}
app, command, ok := r.command(workspaceID, commandID)
if !ok {
return cliservice.CommandOutput{}, cliservice.ErrCommandNotFound
}
if request.OutputMode == "" {
request.OutputMode = serviceOutputMode(command.Capability.Output.DefaultMode)
}
input, warnings, err := appclicore.NormalizeInputWithWarnings(command.Manifest.InputSchema, request.Input)
if err != nil {
return cliservice.CommandOutput{}, serviceInvokeError(err)
}
baseURL, err := r.ensureRunning(ctx, workspaceID, app.AppID, app.BaseURL)
if err != nil {
return cliservice.CommandOutput{}, cliservice.ServiceUnavailableError("app_cli_runtime_unavailable", err)
}
output, err := appclicore.InvokeHTTP(ctx, appclicore.HTTPInvokeRequest{
BaseURL: baseURL,
Command: command,
AppID: app.AppID,
Scope: app.Scope,
WorkspaceID: workspaceID,
Input: input,
OutputMode: coreOutputMode(request.OutputMode),
Context: appclicore.InvokeContext{
Source: request.Context.Source,
ParentCommandID: request.Context.ParentCommandID,
},
HTTPClient: r.HTTPClient,
})
if err != nil {
return cliservice.CommandOutput{}, serviceInvokeError(err)
}
output, err = appclicore.ValidateCommandOutput(command.Capability.Output, output)
if err != nil {
return cliservice.CommandOutput{}, serviceInvokeError(err)
}
if err := appclicore.ValidateCommandContinuation(command.Capability.Execution, output); err != nil {
return cliservice.CommandOutput{}, serviceInvokeError(err)
}
result := serviceCommandOutput(output)
result.Warnings = serviceInputWarnings(warnings)
return result, nil
}
func (r *Registry) command(workspaceID string, commandID string) (appclicore.RegisteredApp, appclicore.Command, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
scopeSet := r.scopeSet(workspaceID)
if scopeSet == nil {
return appclicore.RegisteredApp{}, appclicore.Command{}, false
}
return scopeSet.Command(commandID)
}
func (r *Registry) ensureRunning(ctx context.Context, workspaceID string, appID string, fallbackBaseURL string) (string, error) {
if r.Runtime == nil {
if strings.TrimSpace(fallbackBaseURL) == "" {
return "", errors.New("app cli runtime controller is unavailable")
}
return strings.TrimRight(strings.TrimSpace(fallbackBaseURL), "/"), nil
}
baseURL, err := r.Runtime.EnsureAppRunningForCLI(ctx, workspaceID, appID)
if err != nil {
return "", err
}
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return "", errors.New("app runtime base url is unavailable")
}
r.mu.Lock()
if scopeSet := r.scopeSet(workspaceID); scopeSet != nil {
scopeSet.UpdateBaseURL(appID, baseURL)
}
r.mu.Unlock()
return baseURL, nil
}
func (r *Registry) setError(workspaceID string, appID string, scope string, code string, message string) workspacebiz.AppCLIState {
r.mu.Lock()
defer r.mu.Unlock()
scopeSet := r.scopeSetLocked(workspaceID)
return serviceState(scopeSet.SetError(appID, scope, appclicore.Issue{Code: code, Message: message}))
}
func (r *Registry) resolveWorkspaceID(ctx context.Context, requested string) (string, error) {
if r.Workspaces == nil {
return strings.TrimSpace(requested), nil
}
return cliservice.ResolveWorkspaceID(ctx, r.Workspaces, requested)
}
func (r *Registry) scopeSet(workspaceID string) *appclicore.ScopeSet {
if r.scopeSets == nil {
return nil
}
return r.scopeSets[strings.TrimSpace(workspaceID)]
}
func (r *Registry) scopeSetLocked(workspaceID string) *appclicore.ScopeSet {
if r.scopeSets == nil {
r.scopeSets = map[string]*appclicore.ScopeSet{}
}
workspaceID = strings.TrimSpace(workspaceID)
if r.scopeSets[workspaceID] == nil {
r.scopeSets[workspaceID] = appclicore.NewScopeSet(appclicore.ScopeSetOptions{
ReservedScopes: reservedScopes,
IssueMessages: appclicore.ScopeIssueMessages{
Reserved: func(scope string) appclicore.Issue {
return appclicore.Issue{
Code: "app_cli_scope_reserved",
Message: fmt.Sprintf("CLI scope %q is reserved by Tutti.", scope),
}
},
Conflict: func(scope string, winnerAppID string) appclicore.Issue {
return appclicore.Issue{
Code: "app_cli_scope_conflict",
Message: fmt.Sprintf("CLI scope %q is already provided by app %q.", scope, winnerAppID),
}
},
},
})
}
return r.scopeSets[workspaceID]
}
type documentationError struct {
code string
err error
}
func (e documentationError) Error() string {
if e.err == nil {
return ""
}
return e.err.Error()
}
func (e documentationError) Unwrap() error {
return e.err
}
func errCode(err error) string {
var docErr documentationError
if errors.As(err, &docErr) {
return docErr.code
}
return "app_cli_documentation_path_invalid"
}
func resolveDocumentation(packageDir string, manifest Manifest) (string, string, error) {
if manifest.Documentation == nil {
return "", "", nil
}
documentationFile := strings.TrimSpace(manifest.Documentation.File)
resolvedDocumentationPath, err := CLIManifestPath(packageDir, documentationFile)
if err != nil {
return "", "", documentationError{code: "app_cli_documentation_path_invalid", err: err}
}
if info, err := os.Stat(resolvedDocumentationPath); err != nil || info.IsDir() {
if err == nil {
err = fmt.Errorf("documentation file %q is a directory", documentationFile)
}
return "", "", documentationError{code: "app_cli_documentation_missing", err: err}
}
absoluteDocumentationPath, err := filepath.Abs(resolvedDocumentationPath)
if err != nil {
return "", "", documentationError{code: "app_cli_documentation_path_invalid", err: err}
}
return documentationFile, absoluteDocumentationPath, nil
}
func serviceInvokeError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, appclicore.ErrInvalidInput) {
return fmt.Errorf("%w: %s", cliservice.ErrInvalidInput, err.Error())
}
if errors.Is(err, appclicore.ErrServiceUnavailable) {
reason := appclicore.InvokeErrorReason(err)
if reason == "" {
reason = "app_cli_runtime_unavailable"
}
return cliservice.ServiceUnavailableError(reason, err)
}
if errors.Is(err, appclicore.ErrHandlerBadResponse) || errors.Is(err, appclicore.ErrHandlerFailed) {
reason := appclicore.InvokeErrorReason(err)
if reason == "" {
reason = "app_cli_handler_bad_response"
}
return cliservice.WorkspaceOperationError(reason, err)
}
return err
}
func serviceCapabilities(capabilities []appclicore.Capability) []cliservice.Capability {
if len(capabilities) == 0 {
return []cliservice.Capability{}
}
result := make([]cliservice.Capability, 0, len(capabilities))
for _, capability := range capabilities {
result = append(result, serviceCapability(capability))
}
return result
}
func serviceCapability(capability appclicore.Capability) cliservice.Capability {
return cliservice.Capability{
ID: capability.ID,
Path: append([]string(nil), capability.Path...),
Summary: capability.Summary,
Description: capability.Description,
Visibility: serviceCapabilityVisibility(capability.Visibility),
InputSchema: appclicore.CloneSchema(capability.InputSchema),
Output: serviceCapabilityOutput(capability.Output),
Execution: serviceCommandExecution(capability.Execution),
HandlerTimeoutMs: capability.HandlerTimeoutMs,
Source: serviceCapabilitySource(capability.Source),
}
}
func serviceCommandExecution(execution *appclicore.CommandExecution) *cliservice.CommandExecution {
if execution == nil {
return nil
}
return &cliservice.CommandExecution{Mode: cliservice.CommandExecutionMode(execution.Mode)}
}
func serviceCapabilityVisibility(visibility appclicore.CommandVisibility) cliservice.CapabilityVisibility {
switch appclicore.NormalizeVisibility(visibility) {
case appclicore.CommandVisibilityIntegration:
return cliservice.CapabilityVisibilityIntegration
default:
return cliservice.CapabilityVisibilityPublic
}
}
func serviceCapabilityOutput(output appclicore.CapabilityOutput) cliservice.CapabilityOutput {
return cliservice.CapabilityOutput{
DefaultMode: serviceOutputMode(output.DefaultMode),
JSON: output.JSON,
Table: serviceTableOutput(output.Table),
}
}
func serviceCapabilitySource(source appclicore.CapabilitySource) cliservice.CapabilitySource {
return cliservice.CapabilitySource{
Kind: cliservice.CapabilitySourceApp,
AppID: source.AppID,
AppName: source.AppName,
IconURL: source.IconURL,
CLIDescription: source.CLIDescription,
AppDescription: source.AppDescription,
DocumentationFile: source.DocumentationFile,
DocumentationPath: source.DocumentationPath,
}
}
func serviceTableOutput(output *appclicore.TableOutput) *cliservice.TableOutput {
if output == nil {
return nil
}
return &cliservice.TableOutput{Columns: serviceTableColumns(output.Columns)}
}
func serviceCommandOutput(output appclicore.CommandOutput) cliservice.CommandOutput {
result := cliservice.CommandOutput{
Kind: serviceOutputMode(output.Kind),
Columns: serviceTableColumns(output.Columns),
Rows: output.Rows,
Value: output.Value,
Text: output.Text,
}
if output.Continuation != nil {
result.Continuation = &cliservice.CommandContinuation{
State: cliservice.CommandContinuationState(output.Continuation.State), RetryAfterMs: output.Continuation.RetryAfterMs,
}
}
return result
}
func serviceInputWarnings(warnings []appclicore.InputWarning) []cliservice.CommandWarning {
if len(warnings) == 0 {
return nil
}
result := make([]cliservice.CommandWarning, 0, len(warnings))
for _, warning := range warnings {
result = append(result, cliservice.CommandWarning{
Code: warning.Code,
Message: warning.Message,
})
}
return result
}
func serviceTableColumns(columns []appclicore.TableColumn) []cliservice.TableColumn {
if len(columns) == 0 {
return nil
}
result := make([]cliservice.TableColumn, 0, len(columns))
for _, column := range columns {
result = append(result, cliservice.TableColumn{Key: column.Key, Label: column.Label})
}
return result
}
func serviceOutputMode(mode appclicore.OutputMode) cliservice.OutputMode {
return cliservice.OutputMode(mode)
}
func coreOutputMode(mode cliservice.OutputMode) appclicore.OutputMode {
return appclicore.OutputMode(mode)
}
func serviceState(state appclicore.State) workspacebiz.AppCLIState {
if state.Status == "" {
return workspacebiz.AppCLIState{}
}
return workspacebiz.AppCLIState{
Status: workspacebiz.AppCLIStatus(state.Status),
Scope: state.Scope,
Active: state.Active,
Issues: serviceIssues(state.Issues),
}
}
func serviceIssues(issues []appclicore.Issue) []workspacebiz.AppCLIIssue {
if len(issues) == 0 {
return nil
}
result := make([]workspacebiz.AppCLIIssue, 0, len(issues))
for _, issue := range issues {
result = append(result, workspacebiz.AppCLIIssue{Code: issue.Code, Message: issue.Message})
}
return result
}