源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# App CLI Core
|
||||
|
||||
`packages/appcli/core` owns the reusable Go implementation of the App CLI
|
||||
protocol contract used by workspace apps.
|
||||
|
||||
The package is intentionally limited to protocol-level behavior:
|
||||
|
||||
- `tutti.app.cli.v1` manifest reading and validation
|
||||
- command capability construction from a manifest and host-provided app metadata
|
||||
- manifest input normalization
|
||||
- `tutti.app.cli.invoke.v1` HTTP invoke envelopes
|
||||
- handler response decoding and output contract validation
|
||||
- wait-command execution metadata and validated pending continuations
|
||||
- scope conflict and reserved-scope state calculation
|
||||
|
||||
Host products keep their own workspace lookup, durable state, app package
|
||||
metadata, runtime startup, and user-facing status DTO adapters outside this
|
||||
module. Tutti daemon integration lives in `services/tuttid/service/cli/appcli`.
|
||||
|
||||
The protocol strings still use the frozen Tutti App CLI contract for backwards
|
||||
compatibility with existing app manifests and handlers. That does not make this
|
||||
module a Tutti daemon business layer; consumers such as TSH can reuse the
|
||||
protocol core while supplying their own host adapters.
|
||||
|
||||
## Wait commands
|
||||
|
||||
An app command that semantically waits for a run, session, or other durable
|
||||
execution declares `execution.mode` as `wait`. Its handler returns an ordinary
|
||||
final output when the execution reaches a stop point. While the execution is
|
||||
still active, the handler returns a validated `continuation` with state
|
||||
`pending` and a bounded `retryAfterMs` delay.
|
||||
|
||||
The terminal CLI consumes continuations automatically. One visible command
|
||||
therefore remains active across multiple handler invocations; callers do not
|
||||
poll and do not pass a follow flag. The CLI-owned `--timeout-ms` option is an
|
||||
optional total wait deadline. When it expires, the command returns a successful
|
||||
JSON observation with `reason: "wait_timeout"`, `timedOut: true`, and
|
||||
`executionContinues: true`; it never cancels the underlying execution.
|
||||
|
||||
Handler `timeoutMs` remains a per-invocation transport budget. It is not a
|
||||
workflow or total wait timeout.
|
||||
@@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BuildCommands(manifest Manifest, options CommandBuildOptions) []Command {
|
||||
commands := make([]Command, 0, len(manifest.Commands))
|
||||
for _, command := range manifest.Commands {
|
||||
commands = append(commands, Command{
|
||||
Capability: Capability{
|
||||
ID: CommandID(options.AppID, manifest.Scope, command.Path),
|
||||
Path: append([]string{manifest.Scope}, command.Path...),
|
||||
Summary: strings.TrimSpace(command.Summary),
|
||||
Description: strings.TrimSpace(command.Description),
|
||||
Visibility: NormalizeVisibility(command.Visibility),
|
||||
InputSchema: CloneSchema(command.InputSchema),
|
||||
Output: CapabilityOutput{
|
||||
DefaultMode: command.Output.DefaultMode,
|
||||
JSON: command.Output.JSON,
|
||||
Table: tableOutput(command.Output.Table),
|
||||
},
|
||||
Execution: commandExecution(command.Execution),
|
||||
HandlerTimeoutMs: NormalizedTimeoutMs(command.Handler.TimeoutMs),
|
||||
Source: CapabilitySource{
|
||||
Kind: CapabilitySourceApp,
|
||||
AppID: strings.TrimSpace(options.AppID),
|
||||
AppName: strings.TrimSpace(options.AppName),
|
||||
IconURL: strings.TrimSpace(options.IconURL),
|
||||
CLIDescription: strings.TrimSpace(manifest.Description),
|
||||
AppDescription: strings.TrimSpace(options.AppDescription),
|
||||
DocumentationFile: strings.TrimSpace(options.DocumentationFile),
|
||||
DocumentationPath: strings.TrimSpace(options.DocumentationPath),
|
||||
},
|
||||
},
|
||||
Manifest: command,
|
||||
Timeout: time.Duration(NormalizedTimeoutMs(command.Handler.TimeoutMs)) * time.Millisecond,
|
||||
})
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func commandExecution(execution *ManifestCommandExecution) *CommandExecution {
|
||||
if execution == nil {
|
||||
return nil
|
||||
}
|
||||
return &CommandExecution{Mode: execution.Mode}
|
||||
}
|
||||
|
||||
func CommandID(appID string, scope string, path []string) string {
|
||||
parts := []string{"app", strings.TrimSpace(appID), strings.TrimSpace(scope)}
|
||||
parts = append(parts, path...)
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func tableOutput(output *ManifestTableOutput) *TableOutput {
|
||||
if output == nil {
|
||||
return nil
|
||||
}
|
||||
return &TableOutput{Columns: append([]TableColumn(nil), output.Columns...)}
|
||||
}
|
||||
|
||||
func CloneSchema(schema map[string]any) map[string]any {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid app cli input")
|
||||
ErrServiceUnavailable = errors.New("app cli service unavailable")
|
||||
ErrHandlerBadResponse = errors.New("app cli handler bad response")
|
||||
ErrHandlerFailed = errors.New("app cli handler failed")
|
||||
)
|
||||
|
||||
type InvokeError struct {
|
||||
Kind error
|
||||
Reason string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *InvokeError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
message := strings.TrimSpace(e.Reason)
|
||||
if e.Err != nil {
|
||||
if message != "" {
|
||||
message += ": "
|
||||
}
|
||||
message += e.Err.Error()
|
||||
}
|
||||
if message == "" && e.Kind != nil {
|
||||
message = e.Kind.Error()
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func (e *InvokeError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.Kind == nil {
|
||||
return e.Err
|
||||
}
|
||||
if e.Err == nil {
|
||||
return e.Kind
|
||||
}
|
||||
return errors.Join(e.Kind, e.Err)
|
||||
}
|
||||
|
||||
func invokeError(kind error, reason string, err error) error {
|
||||
return &InvokeError{Kind: kind, Reason: strings.TrimSpace(reason), Err: err}
|
||||
}
|
||||
|
||||
func InvokeErrorReason(err error) string {
|
||||
var invokeErr *InvokeError
|
||||
if errors.As(err, &invokeErr) {
|
||||
return invokeErr.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/tutti-os/tutti/packages/appcli/core
|
||||
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.5
|
||||
@@ -0,0 +1,118 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeInput(schema map[string]any, input map[string]any) (map[string]any, error) {
|
||||
normalized, _, err := NormalizeInputWithWarnings(schema, input)
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func NormalizeInputWithWarnings(schema map[string]any, input map[string]any) (map[string]any, []InputWarning, error) {
|
||||
if len(schema) == 0 {
|
||||
return map[string]any{}, unknownInputWarnings(input, nil), nil
|
||||
}
|
||||
properties, _ := schema["properties"].(map[string]any)
|
||||
required := map[string]bool{}
|
||||
for _, name := range RequiredNames(schema) {
|
||||
required[name] = true
|
||||
}
|
||||
result := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
property, ok := properties[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
propertyMap, _ := property.(map[string]any)
|
||||
normalized, err := normalizeValue(schemaType(propertyMap), value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: invalid input %q", ErrInvalidInput, key)
|
||||
}
|
||||
result[key] = normalized
|
||||
}
|
||||
for name := range required {
|
||||
if _, ok := result[name]; !ok {
|
||||
return nil, nil, fmt.Errorf("%w: required input %q is missing", ErrInvalidInput, name)
|
||||
}
|
||||
}
|
||||
return result, unknownInputWarnings(input, properties), nil
|
||||
}
|
||||
|
||||
func unknownInputWarnings(input map[string]any, properties map[string]any) []InputWarning {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
unknown := make([]string, 0)
|
||||
for key := range input {
|
||||
if _, ok := properties[key]; !ok {
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
warnings := make([]InputWarning, 0, len(unknown))
|
||||
for _, key := range unknown {
|
||||
warnings = append(warnings, InputWarning{
|
||||
Code: "unknown_input_ignored",
|
||||
Message: fmt.Sprintf("Ignoring unknown input %q; this CLI may be newer than the app handler.", key),
|
||||
})
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func normalizeValue(typeName string, value any) (any, error) {
|
||||
switch typeName {
|
||||
case "string":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("not a string")
|
||||
}
|
||||
return text, nil
|
||||
case "boolean":
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, errors.New("not a boolean")
|
||||
}
|
||||
case "integer":
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, nil
|
||||
case int64:
|
||||
return typed, nil
|
||||
case float64:
|
||||
if typed != float64(int64(typed)) {
|
||||
return nil, errors.New("not an integer")
|
||||
}
|
||||
return int64(typed), nil
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, errors.New("not an integer")
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unsupported type")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeInputIgnoresUnknownAndCoercesScalars(t *testing.T) {
|
||||
input, err := NormalizeInput(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"count": map[string]any{"type": "integer"},
|
||||
"dry-run": map[string]any{"type": "boolean"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"count"},
|
||||
}, map[string]any{
|
||||
"count": "2",
|
||||
"dry-run": "true",
|
||||
"name": "daily",
|
||||
"unknown": "x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInput() error = %v", err)
|
||||
}
|
||||
if input["count"] != int64(2) || input["dry-run"] != true || input["name"] != "daily" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
if _, ok := input["unknown"]; ok {
|
||||
t.Fatalf("unknown input was forwarded: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInputWithWarningsReportsUnknownInput(t *testing.T) {
|
||||
input, warnings, err := NormalizeInputWithWarnings(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
}, map[string]any{
|
||||
"name": "daily",
|
||||
"schedule": "0 9 * * *",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeInputWithWarnings() error = %v", err)
|
||||
}
|
||||
if input["name"] != "daily" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
if len(warnings) != 1 ||
|
||||
warnings[0].Code != "unknown_input_ignored" ||
|
||||
!strings.Contains(warnings[0].Message, `"schedule"`) {
|
||||
t.Fatalf("warnings = %#v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInputRequiresRequiredProperties(t *testing.T) {
|
||||
_, err := NormalizeInput(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"name": map[string]any{"type": "string"}},
|
||||
"required": []any{"name"},
|
||||
}, map[string]any{})
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("NormalizeInput() error = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const InvokeSchemaVersion = "tutti.app.cli.invoke.v1"
|
||||
|
||||
type InvokeContext struct {
|
||||
Source string
|
||||
ParentCommandID string
|
||||
}
|
||||
|
||||
type HTTPInvokeRequest struct {
|
||||
BaseURL string
|
||||
Command Command
|
||||
AppID string
|
||||
Scope string
|
||||
WorkspaceID string
|
||||
Input map[string]any
|
||||
OutputMode OutputMode
|
||||
Context InvokeContext
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type InvokeEnvelope struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
CommandID string `json:"commandId"`
|
||||
AppID string `json:"appId"`
|
||||
Scope string `json:"scope"`
|
||||
Path []string `json:"path"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Input map[string]any `json:"input"`
|
||||
OutputMode OutputMode `json:"outputMode"`
|
||||
Context InvokeEnvelopeContext `json:"context"`
|
||||
}
|
||||
|
||||
type InvokeEnvelopeContext struct {
|
||||
Source string `json:"source"`
|
||||
ParentCommandID *string `json:"parentCommandId"`
|
||||
}
|
||||
|
||||
func BuildInvokeEnvelope(request HTTPInvokeRequest) InvokeEnvelope {
|
||||
return InvokeEnvelope{
|
||||
SchemaVersion: InvokeSchemaVersion,
|
||||
CommandID: request.Command.Capability.ID,
|
||||
AppID: strings.TrimSpace(request.AppID),
|
||||
Scope: strings.TrimSpace(request.Scope),
|
||||
Path: append([]string(nil), request.Command.Manifest.Path...),
|
||||
WorkspaceID: strings.TrimSpace(request.WorkspaceID),
|
||||
Input: request.Input,
|
||||
OutputMode: request.OutputMode,
|
||||
Context: InvokeEnvelopeContext{
|
||||
Source: firstNonEmpty(request.Context.Source, "cli"),
|
||||
ParentCommandID: nullableString(request.Context.ParentCommandID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func InvokeHTTP(ctx context.Context, request HTTPInvokeRequest) (CommandOutput, error) {
|
||||
endpoint, err := url.JoinPath(strings.TrimRight(strings.TrimSpace(request.BaseURL), "/"), request.Command.Manifest.Handler.Path)
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
body, err := json.Marshal(BuildInvokeEnvelope(request))
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
timeout := request.Command.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(DefaultTimeoutMs) * time.Millisecond
|
||||
}
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
httpRequest, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := httpClient(request.HTTPClient).Do(httpRequest)
|
||||
if err != nil {
|
||||
if errors.Is(requestCtx.Err(), context.DeadlineExceeded) {
|
||||
return CommandOutput{}, invokeError(ErrServiceUnavailable, "app_cli_handler_timeout", err)
|
||||
}
|
||||
if isConnectionUnavailable(err) {
|
||||
return CommandOutput{}, invokeError(ErrServiceUnavailable, "app_cli_runtime_unavailable", err)
|
||||
}
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
if validAppErrorBody(content) {
|
||||
return CommandOutput{}, invokeError(ErrHandlerFailed, "app_cli_handler_failed", errors.New(appErrorMessage(content)))
|
||||
}
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("app cli handler returned %s", response.Status))
|
||||
}
|
||||
output, err := DecodeCommandOutput(content)
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func httpClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return &http.Client{Timeout: MaxTimeoutMs*time.Millisecond + 30*time.Second}
|
||||
}
|
||||
|
||||
func nullableString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isConnectionUnavailable(err error) bool {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "connection refused")
|
||||
}
|
||||
|
||||
func validAppErrorBody(content []byte) bool {
|
||||
var body struct {
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &body); err != nil {
|
||||
return false
|
||||
}
|
||||
return body.Error != nil && strings.TrimSpace(body.Error.Code) != "" && strings.TrimSpace(body.Error.Message) != ""
|
||||
}
|
||||
|
||||
func appErrorMessage(content []byte) string {
|
||||
var body struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &body); err == nil && strings.TrimSpace(body.Error.Message) != "" {
|
||||
return strings.TrimSpace(body.Error.Message)
|
||||
}
|
||||
return "app cli handler failed"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInvokeHTTPPostsEnvelope(t *testing.T) {
|
||||
var envelope map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/tutti/cli/run" {
|
||||
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"kind":"json","value":{"ok":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
output, err := InvokeHTTP(context.Background(), HTTPInvokeRequest{
|
||||
BaseURL: server.URL,
|
||||
AppID: "automation-app",
|
||||
Scope: "automation",
|
||||
Command: Command{
|
||||
Capability: Capability{ID: "app.automation-app.automation.run"},
|
||||
Manifest: ManifestCommand{
|
||||
Path: []string{"run"},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
|
||||
},
|
||||
Timeout: time.Second,
|
||||
},
|
||||
WorkspaceID: "ws-1",
|
||||
Input: map[string]any{"name": "daily"},
|
||||
OutputMode: OutputModeJSON,
|
||||
Context: InvokeContext{Source: "cli", ParentCommandID: "parent"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InvokeHTTP() error = %v", err)
|
||||
}
|
||||
if output.Kind != OutputModeJSON || output.Value["ok"] != true {
|
||||
t.Fatalf("output = %#v", output)
|
||||
}
|
||||
if envelope["schemaVersion"] != InvokeSchemaVersion || envelope["workspaceId"] != "ws-1" {
|
||||
t.Fatalf("envelope = %#v", envelope)
|
||||
}
|
||||
contextValue := envelope["context"].(map[string]any)
|
||||
if contextValue["source"] != "cli" || contextValue["parentCommandId"] != "parent" {
|
||||
t.Fatalf("envelope context = %#v", contextValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ManifestSchemaVersion = "tutti.app.cli.v1"
|
||||
maxManifestBytes = 256 * 1024
|
||||
DefaultTimeoutMs = 30000
|
||||
MinTimeoutMs = 1000
|
||||
MaxTimeoutMs = 600000
|
||||
)
|
||||
|
||||
const (
|
||||
CommandVisibilityPublic CommandVisibility = "public"
|
||||
CommandVisibilityIntegration CommandVisibility = "integration"
|
||||
)
|
||||
|
||||
var segmentPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Scope string `json:"scope"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Documentation *ManifestDocumentation `json:"documentation,omitempty"`
|
||||
Commands []ManifestCommand `json:"commands"`
|
||||
}
|
||||
|
||||
type ManifestDocumentation struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
type ManifestCommand struct {
|
||||
Path []string `json:"path"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Visibility CommandVisibility `json:"visibility,omitempty"`
|
||||
Execution *ManifestCommandExecution `json:"execution,omitempty"`
|
||||
InputSchema map[string]any `json:"inputSchema,omitempty"`
|
||||
Output ManifestCommandOutput `json:"output"`
|
||||
Handler ManifestCommandHandler `json:"handler"`
|
||||
}
|
||||
|
||||
type ManifestCommandExecution struct {
|
||||
Mode CommandExecutionMode `json:"mode"`
|
||||
}
|
||||
|
||||
type ManifestCommandOutput struct {
|
||||
DefaultMode OutputMode `json:"defaultMode"`
|
||||
JSON bool `json:"json"`
|
||||
Table *ManifestTableOutput `json:"table,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestTableOutput struct {
|
||||
Columns []TableColumn `json:"columns"`
|
||||
}
|
||||
|
||||
type ManifestCommandHandler struct {
|
||||
Kind string `json:"kind"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
func ReadManifest(path string) (Manifest, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("read cli manifest: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return Manifest{}, fmt.Errorf("stat cli manifest: %w", err)
|
||||
}
|
||||
if info.Size() > maxManifestBytes {
|
||||
return Manifest{}, fmt.Errorf("cli manifest exceeds maximum size %d", maxManifestBytes)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(file)
|
||||
decoder.UseNumber()
|
||||
var manifest Manifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("parse cli manifest json: %w", err)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func ValidateManifest(manifest Manifest) error {
|
||||
if !isSupportedManifestSchemaVersion(strings.TrimSpace(manifest.SchemaVersion)) {
|
||||
return fmt.Errorf("unsupported cli manifest schema version %q", manifest.SchemaVersion)
|
||||
}
|
||||
scope := strings.TrimSpace(manifest.Scope)
|
||||
if err := validateSegment(scope, "scope"); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.Documentation != nil {
|
||||
if err := validatePackageRelativePath(manifest.Documentation.File, "documentation.file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(manifest.Commands) == 0 {
|
||||
return errors.New("cli manifest commands is required")
|
||||
}
|
||||
|
||||
seenPaths := map[string]struct{}{}
|
||||
for index, command := range manifest.Commands {
|
||||
location := fmt.Sprintf("commands[%d]", index)
|
||||
if len(command.Path) == 0 {
|
||||
return fmt.Errorf("cli manifest %s.path is required", location)
|
||||
}
|
||||
if command.Path[0] == scope {
|
||||
return fmt.Errorf("cli manifest %s.path must not repeat scope", location)
|
||||
}
|
||||
for segmentIndex, segment := range command.Path {
|
||||
if err := validateSegment(strings.TrimSpace(segment), fmt.Sprintf("%s.path[%d]", location, segmentIndex)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pathKey := strings.Join(command.Path, ".")
|
||||
if _, ok := seenPaths[pathKey]; ok {
|
||||
return fmt.Errorf("cli manifest command path %q is duplicated", strings.Join(command.Path, " "))
|
||||
}
|
||||
seenPaths[pathKey] = struct{}{}
|
||||
|
||||
if strings.TrimSpace(command.Summary) == "" {
|
||||
return fmt.Errorf("cli manifest %s.summary is required", location)
|
||||
}
|
||||
if err := validateVisibility(command.Visibility, location+".visibility"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateExecution(command.Execution, command.InputSchema, command.Output, location+".execution"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputSchema(command.InputSchema, location+".inputSchema"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOutput(command.Output, location+".output"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateHandler(command.Handler, location+".handler"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExecution(execution *ManifestCommandExecution, inputSchema map[string]any, output ManifestCommandOutput, location string) error {
|
||||
if execution == nil {
|
||||
return nil
|
||||
}
|
||||
if execution.Mode != CommandExecutionModeWait {
|
||||
return fmt.Errorf("cli manifest %s.mode must be wait", location)
|
||||
}
|
||||
if output.DefaultMode != OutputModeJSON || !output.JSON {
|
||||
return fmt.Errorf("cli manifest %s mode wait requires json default output", location)
|
||||
}
|
||||
properties, _ := inputSchema["properties"].(map[string]any)
|
||||
if _, declared := properties["timeout-ms"]; declared {
|
||||
return fmt.Errorf("cli manifest %s mode wait reserves --timeout-ms for the total CLI wait timeout", location)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeVisibility(visibility CommandVisibility) CommandVisibility {
|
||||
switch CommandVisibility(strings.TrimSpace(string(visibility))) {
|
||||
case "", CommandVisibilityPublic:
|
||||
return CommandVisibilityPublic
|
||||
case CommandVisibilityIntegration:
|
||||
return CommandVisibilityIntegration
|
||||
default:
|
||||
return visibility
|
||||
}
|
||||
}
|
||||
|
||||
func validateVisibility(visibility CommandVisibility, location string) error {
|
||||
switch NormalizeVisibility(visibility) {
|
||||
case CommandVisibilityPublic, CommandVisibilityIntegration:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("cli manifest %s must be public or integration", location)
|
||||
}
|
||||
}
|
||||
|
||||
func isSupportedManifestSchemaVersion(schemaVersion string) bool {
|
||||
return schemaVersion == ManifestSchemaVersion
|
||||
}
|
||||
|
||||
func validateSegment(segment string, location string) error {
|
||||
if segment == "" {
|
||||
return fmt.Errorf("cli manifest %s is required", location)
|
||||
}
|
||||
if strings.HasPrefix(segment, "--") || strings.Contains(segment, " ") || !segmentPattern.MatchString(segment) {
|
||||
return fmt.Errorf("cli manifest %s must contain lowercase letters, numbers, and hyphen only", location)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePackageRelativePath(value string, location string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fmt.Errorf("cli manifest %s is required", location)
|
||||
}
|
||||
if filepath.IsAbs(value) || strings.HasPrefix(value, `\`) {
|
||||
return fmt.Errorf("cli manifest %s must be a relative package path", location)
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(value, func(char rune) bool {
|
||||
return char == '/' || char == '\\'
|
||||
}) {
|
||||
if part == ".." {
|
||||
return fmt.Errorf("cli manifest %s must not contain parent path segments", location)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputSchema(schema map[string]any, location string) error {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
}
|
||||
if schemaType(schema) != "object" {
|
||||
return fmt.Errorf("cli manifest %s.type must be object", location)
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cli manifest %s.properties is required", location)
|
||||
}
|
||||
for name, property := range properties {
|
||||
if err := validateSegment(strings.TrimSpace(name), location+".properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
propertyMap, ok := property.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cli manifest %s.properties.%s must be an object", location, name)
|
||||
}
|
||||
propertyType := schemaType(propertyMap)
|
||||
switch propertyType {
|
||||
case "string", "boolean", "integer":
|
||||
default:
|
||||
return fmt.Errorf("cli manifest %s.properties.%s.type must be string, boolean, or integer", location, name)
|
||||
}
|
||||
for key := range propertyMap {
|
||||
if key != "type" && key != "description" && key != "enum" && key != "default" {
|
||||
return fmt.Errorf("cli manifest %s.properties.%s has unsupported key %q", location, name, key)
|
||||
}
|
||||
}
|
||||
if enum, ok := propertyMap["enum"]; ok {
|
||||
if err := validateInputSchemaEnum(propertyType, enum, fmt.Sprintf("%s.properties.%s.enum", location, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if defaultValue, ok := propertyMap["default"]; ok {
|
||||
if err := validateInputSchemaValue(propertyType, defaultValue, fmt.Sprintf("%s.properties.%s.default", location, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, required := range RequiredNames(schema) {
|
||||
if _, ok := properties[required]; !ok {
|
||||
return fmt.Errorf("cli manifest %s.required contains unknown property %q", location, required)
|
||||
}
|
||||
}
|
||||
for key := range schema {
|
||||
if key != "type" && key != "properties" && key != "required" {
|
||||
return fmt.Errorf("cli manifest %s has unsupported key %q", location, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputSchemaEnum(typeName string, value any, location string) error {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("cli manifest %s must be an array", location)
|
||||
}
|
||||
for index, item := range items {
|
||||
if err := validateInputSchemaValue(typeName, item, fmt.Sprintf("%s[%d]", location, index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputSchemaValue(typeName string, value any, location string) error {
|
||||
switch typeName {
|
||||
case "string":
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("cli manifest %s must be a string", location)
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("cli manifest %s must be a boolean", location)
|
||||
}
|
||||
case "integer":
|
||||
switch typed := value.(type) {
|
||||
case int, int64:
|
||||
return nil
|
||||
case float64:
|
||||
if typed == float64(int64(typed)) {
|
||||
return nil
|
||||
}
|
||||
case json.Number:
|
||||
if _, err := typed.Int64(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cli manifest %s must be an integer", location)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutput(output ManifestCommandOutput, location string) error {
|
||||
switch output.DefaultMode {
|
||||
case OutputModeJSON:
|
||||
if !output.JSON {
|
||||
return fmt.Errorf("cli manifest %s.json must be true when defaultMode is json", location)
|
||||
}
|
||||
case OutputModeTable:
|
||||
if output.Table == nil || len(output.Table.Columns) == 0 {
|
||||
return fmt.Errorf("cli manifest %s.table.columns is required when defaultMode is table", location)
|
||||
}
|
||||
case "":
|
||||
return fmt.Errorf("cli manifest %s.defaultMode is required", location)
|
||||
default:
|
||||
return fmt.Errorf("cli manifest %s.defaultMode must be json or table", location)
|
||||
}
|
||||
if output.Table != nil {
|
||||
seen := map[string]struct{}{}
|
||||
for _, column := range output.Table.Columns {
|
||||
key := strings.TrimSpace(column.Key)
|
||||
if err := validateSegment(key, location+".table.columns.key"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(column.Label) == "" {
|
||||
return fmt.Errorf("cli manifest %s.table.columns.label is required", location)
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return fmt.Errorf("cli manifest %s.table.columns key %q is duplicated", location, key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHandler(handler ManifestCommandHandler, location string) error {
|
||||
if strings.TrimSpace(handler.Kind) != "http" {
|
||||
return fmt.Errorf("cli manifest %s.kind must be http", location)
|
||||
}
|
||||
if strings.TrimSpace(handler.Method) != "POST" {
|
||||
return fmt.Errorf("cli manifest %s.method must be POST", location)
|
||||
}
|
||||
handlerPath := strings.TrimSpace(handler.Path)
|
||||
if !strings.HasPrefix(handlerPath, "/tutti/cli/") {
|
||||
return fmt.Errorf("cli manifest %s.path must start with /tutti/cli/", location)
|
||||
}
|
||||
timeoutMs := NormalizedTimeoutMs(handler.TimeoutMs)
|
||||
if timeoutMs < MinTimeoutMs || timeoutMs > MaxTimeoutMs {
|
||||
return fmt.Errorf("cli manifest %s.timeoutMs must be between %d and %d", location, MinTimeoutMs, MaxTimeoutMs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizedTimeoutMs(value int) int {
|
||||
if value == 0 {
|
||||
return DefaultTimeoutMs
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func schemaType(schema map[string]any) string {
|
||||
value, _ := schema["type"].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func RequiredNames(schema map[string]any) []string {
|
||||
value, ok := schema["required"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case []any:
|
||||
names := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if name, ok := item.(string); ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func PackageRelativePath(packageDir string, manifestPath string) (string, error) {
|
||||
manifestPath = strings.TrimSpace(manifestPath)
|
||||
if manifestPath == "" {
|
||||
return "", errors.New("cli manifest path is required")
|
||||
}
|
||||
if filepath.IsAbs(manifestPath) || strings.HasPrefix(manifestPath, `\`) {
|
||||
return "", errors.New("cli manifest path must be relative")
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(manifestPath, func(char rune) bool {
|
||||
return char == '/' || char == '\\'
|
||||
}) {
|
||||
if part == ".." {
|
||||
return "", errors.New("cli manifest path must not contain parent path segments")
|
||||
}
|
||||
}
|
||||
return filepath.Join(packageDir, filepath.FromSlash(manifestPath)), nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateManifestRejectsReservedHandlerPath(t *testing.T) {
|
||||
err := ValidateManifest(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"run"},
|
||||
Summary: "Run automation",
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/run"},
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ValidateManifest() error = nil, want handler path error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsUnknownVisibility(t *testing.T) {
|
||||
err := ValidateManifest(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"run"},
|
||||
Summary: "Run automation",
|
||||
Visibility: "private",
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ValidateManifest() error = nil, want visibility error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestAcceptsEnumAndDefaultInputAnnotations(t *testing.T) {
|
||||
err := ValidateManifest(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"create"},
|
||||
Summary: "Create automation",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"schedule-type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Schedule type.",
|
||||
"enum": []any{"manual", "interval", "daily", "weekly", "cron"},
|
||||
"default": "manual",
|
||||
},
|
||||
"enabled": map[string]any{
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
},
|
||||
"interval-minutes": map[string]any{
|
||||
"type": "integer",
|
||||
"default": 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/create"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateManifest() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsEnumValuesWithWrongType(t *testing.T) {
|
||||
err := ValidateManifest(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"create"},
|
||||
Summary: "Create automation",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"schedule-type": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"manual", 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/create"},
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ValidateManifest() error = nil, want enum type error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandsBuildsAppCapability(t *testing.T) {
|
||||
manifest := Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Description: "Automation commands",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"run"},
|
||||
Summary: "Run automation",
|
||||
Description: "Runs a job",
|
||||
Visibility: CommandVisibilityIntegration,
|
||||
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"name": map[string]any{"type": "string"}}},
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
|
||||
}},
|
||||
}
|
||||
|
||||
commands := BuildCommands(manifest, CommandBuildOptions{
|
||||
AppID: "automation-app",
|
||||
AppName: "Automation",
|
||||
IconURL: "data:image/png;base64,abc",
|
||||
AppDescription: "Test app",
|
||||
DocumentationFile: "COMMANDS.md",
|
||||
DocumentationPath: "/tmp/COMMANDS.md",
|
||||
})
|
||||
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("commands = %#v", commands)
|
||||
}
|
||||
capability := commands[0].Capability
|
||||
if capability.ID != "app.automation-app.automation.run" || capability.Path[0] != "automation" {
|
||||
t.Fatalf("capability = %#v", capability)
|
||||
}
|
||||
if capability.Source.AppID != "automation-app" || capability.Source.CLIDescription != "Automation commands" {
|
||||
t.Fatalf("capability source = %#v", capability.Source)
|
||||
}
|
||||
if capability.Visibility != CommandVisibilityIntegration {
|
||||
t.Fatalf("capability visibility = %q", capability.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandsDefaultsVisibilityToPublic(t *testing.T) {
|
||||
commands := BuildCommands(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"run"},
|
||||
Summary: "Run automation",
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/run"},
|
||||
}},
|
||||
}, CommandBuildOptions{AppID: "automation-app"})
|
||||
|
||||
if len(commands) != 1 || commands[0].Capability.Visibility != CommandVisibilityPublic {
|
||||
t.Fatalf("commands = %#v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestAcceptsWaitExecutionAndBuildsCapabilityMetadata(t *testing.T) {
|
||||
manifest := Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"runs", "wait"},
|
||||
Summary: "Wait for a run",
|
||||
Execution: &ManifestCommandExecution{Mode: CommandExecutionModeWait},
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{
|
||||
Kind: "http", Method: "POST", Path: "/tutti/cli/runs/wait", TimeoutMs: 45000,
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
t.Fatalf("ValidateManifest() error = %v", err)
|
||||
}
|
||||
commands := BuildCommands(manifest, CommandBuildOptions{AppID: "automation-app"})
|
||||
if len(commands) != 1 || commands[0].Capability.Execution == nil ||
|
||||
commands[0].Capability.Execution.Mode != CommandExecutionModeWait ||
|
||||
commands[0].Capability.HandlerTimeoutMs != 45000 {
|
||||
t.Fatalf("commands = %#v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsWaitExecutionTimeoutInput(t *testing.T) {
|
||||
err := ValidateManifest(Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion,
|
||||
Scope: "automation",
|
||||
Commands: []ManifestCommand{{
|
||||
Path: []string{"runs", "wait"},
|
||||
Summary: "Wait for a run",
|
||||
Execution: &ManifestCommandExecution{Mode: CommandExecutionModeWait},
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"timeout-ms": map[string]any{"type": "integer"},
|
||||
},
|
||||
},
|
||||
Output: ManifestCommandOutput{DefaultMode: OutputModeJSON, JSON: true},
|
||||
Handler: ManifestCommandHandler{Kind: "http", Method: "POST", Path: "/tutti/cli/runs/wait"},
|
||||
}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "reserves --timeout-ms") {
|
||||
t.Fatalf("ValidateManifest() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func DecodeCommandOutput(content []byte) (CommandOutput, error) {
|
||||
var raw struct {
|
||||
Kind OutputMode `json:"kind"`
|
||||
Columns []TableColumn `json:"columns"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
Value map[string]any `json:"value"`
|
||||
Text string `json:"text"`
|
||||
Continuation *struct {
|
||||
State CommandContinuationState `json:"state"`
|
||||
RetryAfterMs int `json:"retryAfterMs"`
|
||||
} `json:"continuation"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
return CommandOutput{}, err
|
||||
}
|
||||
if raw.Kind == "" {
|
||||
return CommandOutput{}, errors.New("cli command output kind is required")
|
||||
}
|
||||
output := CommandOutput{
|
||||
Kind: raw.Kind,
|
||||
Columns: raw.Columns,
|
||||
Rows: raw.Rows,
|
||||
Value: raw.Value,
|
||||
Text: raw.Text,
|
||||
}
|
||||
if raw.Continuation != nil {
|
||||
output.Continuation = &CommandContinuation{
|
||||
State: raw.Continuation.State, RetryAfterMs: raw.Continuation.RetryAfterMs,
|
||||
}
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func ValidateCommandOutput(contract CapabilityOutput, output CommandOutput) (CommandOutput, error) {
|
||||
switch output.Kind {
|
||||
case OutputModeJSON:
|
||||
if !contract.JSON {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("json output is not declared"))
|
||||
}
|
||||
case OutputModeTable:
|
||||
if contract.Table == nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("table output is not declared"))
|
||||
}
|
||||
columns, err := normalizeOutputColumns(contract.Table.Columns, output.Columns)
|
||||
if err != nil {
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", err)
|
||||
}
|
||||
output.Columns = columns
|
||||
default:
|
||||
return CommandOutput{}, invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("unsupported output kind %q", output.Kind))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func ValidateCommandContinuation(execution *CommandExecution, output CommandOutput) error {
|
||||
continuation := output.Continuation
|
||||
if continuation == nil {
|
||||
return nil
|
||||
}
|
||||
if execution == nil || execution.Mode != CommandExecutionModeWait {
|
||||
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("continuation is only allowed for wait commands"))
|
||||
}
|
||||
if output.Kind != OutputModeJSON {
|
||||
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", errors.New("wait continuation requires json output"))
|
||||
}
|
||||
if continuation.State != CommandContinuationStatePending {
|
||||
return invokeError(ErrHandlerBadResponse, "app_cli_handler_bad_response", fmt.Errorf("unsupported continuation state %q", continuation.State))
|
||||
}
|
||||
if continuation.RetryAfterMs < MinContinuationRetryAfterMs || continuation.RetryAfterMs > MaxContinuationRetryAfterMs {
|
||||
return invokeError(
|
||||
ErrHandlerBadResponse,
|
||||
"app_cli_handler_bad_response",
|
||||
fmt.Errorf("continuation retryAfterMs must be between %d and %d", MinContinuationRetryAfterMs, MaxContinuationRetryAfterMs),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOutputColumns(contract []TableColumn, actual []TableColumn) ([]TableColumn, error) {
|
||||
if len(actual) == 0 {
|
||||
return append([]TableColumn(nil), contract...), nil
|
||||
}
|
||||
contractByKey := map[string]TableColumn{}
|
||||
for _, column := range contract {
|
||||
contractByKey[column.Key] = column
|
||||
}
|
||||
result := make([]TableColumn, 0, len(actual))
|
||||
for _, column := range actual {
|
||||
expected, ok := contractByKey[column.Key]
|
||||
if !ok || expected.Label != column.Label {
|
||||
return nil, fmt.Errorf("table output column %q is not declared", column.Key)
|
||||
}
|
||||
result = append(result, column)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCommandOutputFillsDeclaredTableColumns(t *testing.T) {
|
||||
output, err := ValidateCommandOutput(CapabilityOutput{
|
||||
DefaultMode: OutputModeTable,
|
||||
JSON: true,
|
||||
Table: &TableOutput{Columns: []TableColumn{{Key: "id", Label: "ID"}}},
|
||||
}, CommandOutput{
|
||||
Kind: OutputModeTable,
|
||||
Rows: []map[string]any{{"id": "job-1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateCommandOutput() error = %v", err)
|
||||
}
|
||||
if len(output.Columns) != 1 || output.Columns[0].Key != "id" {
|
||||
t.Fatalf("output = %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandOutputRejectsUndeclaredJSON(t *testing.T) {
|
||||
_, err := ValidateCommandOutput(CapabilityOutput{
|
||||
DefaultMode: OutputModeTable,
|
||||
Table: &TableOutput{Columns: []TableColumn{{Key: "id", Label: "ID"}}},
|
||||
}, CommandOutput{Kind: OutputModeJSON, Value: map[string]any{"ok": true}})
|
||||
if !errors.Is(err, ErrHandlerBadResponse) {
|
||||
t.Fatalf("ValidateCommandOutput() error = %v, want ErrHandlerBadResponse", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAndValidateWaitContinuation(t *testing.T) {
|
||||
output, err := DecodeCommandOutput([]byte(`{
|
||||
"kind":"json",
|
||||
"value":{"status":"running"},
|
||||
"continuation":{"state":"pending","retryAfterMs":500}
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCommandOutput() error = %v", err)
|
||||
}
|
||||
if err := ValidateCommandContinuation(&CommandExecution{Mode: CommandExecutionModeWait}, output); err != nil {
|
||||
t.Fatalf("ValidateCommandContinuation() error = %v", err)
|
||||
}
|
||||
if output.Continuation == nil || output.Continuation.RetryAfterMs != 500 {
|
||||
t.Fatalf("output = %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandContinuationRejectsHotLoopAndNonWaitCommands(t *testing.T) {
|
||||
output := CommandOutput{
|
||||
Kind: OutputModeJSON,
|
||||
Continuation: &CommandContinuation{
|
||||
State: CommandContinuationStatePending, RetryAfterMs: MinContinuationRetryAfterMs - 1,
|
||||
},
|
||||
}
|
||||
if err := ValidateCommandContinuation(&CommandExecution{Mode: CommandExecutionModeWait}, output); !errors.Is(err, ErrHandlerBadResponse) {
|
||||
t.Fatalf("hot-loop error = %v", err)
|
||||
}
|
||||
output.Continuation.RetryAfterMs = MinContinuationRetryAfterMs
|
||||
if err := ValidateCommandContinuation(nil, output); !errors.Is(err, ErrHandlerBadResponse) {
|
||||
t.Fatalf("non-wait error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusWarning Status = "warning"
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Status Status
|
||||
Scope string
|
||||
Active bool
|
||||
Issues []Issue
|
||||
}
|
||||
|
||||
type ScopeIssueMessages struct {
|
||||
Reserved func(scope string) Issue
|
||||
Conflict func(scope string, winnerAppID string) Issue
|
||||
}
|
||||
|
||||
type ScopeSetOptions struct {
|
||||
ReservedScopes map[string]struct{}
|
||||
IssueMessages ScopeIssueMessages
|
||||
}
|
||||
|
||||
type RegisteredApp struct {
|
||||
AppID string
|
||||
AppName string
|
||||
Scope string
|
||||
BaseURL string
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
type CapabilityListOptions struct {
|
||||
IncludeIntegration bool
|
||||
}
|
||||
|
||||
type CommandRef struct {
|
||||
AppID string
|
||||
CommandID string
|
||||
}
|
||||
|
||||
type ScopeSet struct {
|
||||
reservedScopes map[string]struct{}
|
||||
issueMessages ScopeIssueMessages
|
||||
entries map[string]*scopeEntry
|
||||
states map[string]State
|
||||
commandID map[string]CommandRef
|
||||
}
|
||||
|
||||
type scopeEntry struct {
|
||||
RegisteredApp
|
||||
active bool
|
||||
issues []Issue
|
||||
}
|
||||
|
||||
func NewScopeSet(options ScopeSetOptions) *ScopeSet {
|
||||
reservedScopes := map[string]struct{}{}
|
||||
for scope := range options.ReservedScopes {
|
||||
reservedScopes[strings.TrimSpace(scope)] = struct{}{}
|
||||
}
|
||||
return &ScopeSet{
|
||||
reservedScopes: reservedScopes,
|
||||
issueMessages: options.IssueMessages,
|
||||
entries: map[string]*scopeEntry{},
|
||||
states: map[string]State{},
|
||||
commandID: map[string]CommandRef{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScopeSet) Upsert(app RegisteredApp) State {
|
||||
if s == nil {
|
||||
return State{}
|
||||
}
|
||||
s.ensure()
|
||||
app.AppID = strings.TrimSpace(app.AppID)
|
||||
app.Scope = strings.TrimSpace(app.Scope)
|
||||
if app.AppID == "" {
|
||||
return State{}
|
||||
}
|
||||
s.entries[app.AppID] = &scopeEntry{RegisteredApp: app}
|
||||
s.recompute()
|
||||
return s.State(app.AppID)
|
||||
}
|
||||
|
||||
func (s *ScopeSet) SetError(appID string, scope string, issue Issue) State {
|
||||
if s == nil {
|
||||
return State{}
|
||||
}
|
||||
s.ensure()
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return State{}
|
||||
}
|
||||
delete(s.entries, appID)
|
||||
s.recompute()
|
||||
state := State{
|
||||
Status: StatusError,
|
||||
Scope: strings.TrimSpace(scope),
|
||||
Active: false,
|
||||
Issues: []Issue{issue},
|
||||
}
|
||||
s.states[appID] = state
|
||||
return cloneState(state)
|
||||
}
|
||||
|
||||
func (s *ScopeSet) Remove(appID string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ensure()
|
||||
delete(s.entries, strings.TrimSpace(appID))
|
||||
delete(s.states, strings.TrimSpace(appID))
|
||||
s.recompute()
|
||||
}
|
||||
|
||||
func (s *ScopeSet) Capabilities(options ...CapabilityListOptions) []Capability {
|
||||
if s == nil || len(s.entries) == 0 {
|
||||
return []Capability{}
|
||||
}
|
||||
includeIntegration := false
|
||||
if len(options) > 0 {
|
||||
includeIntegration = options[0].IncludeIntegration
|
||||
}
|
||||
appIDs := make([]string, 0, len(s.entries))
|
||||
for appID := range s.entries {
|
||||
appIDs = append(appIDs, appID)
|
||||
}
|
||||
sort.Strings(appIDs)
|
||||
result := make([]Capability, 0)
|
||||
for _, appID := range appIDs {
|
||||
entry := s.entries[appID]
|
||||
if entry == nil || !entry.active {
|
||||
continue
|
||||
}
|
||||
for _, command := range entry.Commands {
|
||||
if !includeIntegration && NormalizeVisibility(command.Capability.Visibility) == CommandVisibilityIntegration {
|
||||
continue
|
||||
}
|
||||
result = append(result, command.Capability)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ScopeSet) Command(commandID string) (RegisteredApp, Command, bool) {
|
||||
if s == nil {
|
||||
return RegisteredApp{}, Command{}, false
|
||||
}
|
||||
ref, ok := s.commandID[strings.TrimSpace(commandID)]
|
||||
if !ok {
|
||||
return RegisteredApp{}, Command{}, false
|
||||
}
|
||||
entry := s.entries[ref.AppID]
|
||||
if entry == nil || !entry.active {
|
||||
return RegisteredApp{}, Command{}, false
|
||||
}
|
||||
for _, command := range entry.Commands {
|
||||
if command.Capability.ID == ref.CommandID {
|
||||
return entry.RegisteredApp, command, true
|
||||
}
|
||||
}
|
||||
return RegisteredApp{}, Command{}, false
|
||||
}
|
||||
|
||||
func (s *ScopeSet) UpdateBaseURL(appID string, baseURL string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if entry := s.entries[strings.TrimSpace(appID)]; entry != nil {
|
||||
entry.BaseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScopeSet) State(appID string) State {
|
||||
if s == nil {
|
||||
return State{}
|
||||
}
|
||||
state, ok := s.states[strings.TrimSpace(appID)]
|
||||
if !ok {
|
||||
return State{}
|
||||
}
|
||||
return cloneState(state)
|
||||
}
|
||||
|
||||
func (s *ScopeSet) Empty() bool {
|
||||
return s == nil || len(s.entries) == 0 && len(s.states) == 0
|
||||
}
|
||||
|
||||
func (s *ScopeSet) ensure() {
|
||||
if s.entries == nil {
|
||||
s.entries = map[string]*scopeEntry{}
|
||||
}
|
||||
if s.states == nil {
|
||||
s.states = map[string]State{}
|
||||
}
|
||||
if s.commandID == nil {
|
||||
s.commandID = map[string]CommandRef{}
|
||||
}
|
||||
if s.reservedScopes == nil {
|
||||
s.reservedScopes = map[string]struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScopeSet) recompute() {
|
||||
s.ensure()
|
||||
for commandID := range s.commandID {
|
||||
delete(s.commandID, commandID)
|
||||
}
|
||||
for appID, state := range s.states {
|
||||
if state.Status != StatusError {
|
||||
delete(s.states, appID)
|
||||
}
|
||||
}
|
||||
if len(s.entries) == 0 {
|
||||
return
|
||||
}
|
||||
byScope := map[string][]*scopeEntry{}
|
||||
for _, entry := range s.entries {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
entry.active = false
|
||||
entry.issues = nil
|
||||
byScope[entry.Scope] = append(byScope[entry.Scope], entry)
|
||||
}
|
||||
for scope, scopedEntries := range byScope {
|
||||
sort.Slice(scopedEntries, func(left, right int) bool {
|
||||
return scopedEntries[left].AppID < scopedEntries[right].AppID
|
||||
})
|
||||
if _, reserved := s.reservedScopes[scope]; reserved {
|
||||
for _, entry := range scopedEntries {
|
||||
entry.issues = []Issue{s.reservedIssue(scope)}
|
||||
s.states[entry.AppID] = stateFromEntry(entry, StatusWarning)
|
||||
}
|
||||
continue
|
||||
}
|
||||
winner := scopedEntries[0]
|
||||
winner.active = true
|
||||
s.states[winner.AppID] = stateFromEntry(winner, StatusActive)
|
||||
for _, command := range winner.Commands {
|
||||
s.commandID[command.Capability.ID] = CommandRef{
|
||||
AppID: winner.AppID,
|
||||
CommandID: command.Capability.ID,
|
||||
}
|
||||
}
|
||||
for _, loser := range scopedEntries[1:] {
|
||||
loser.issues = []Issue{s.conflictIssue(scope, winner.AppID)}
|
||||
s.states[loser.AppID] = stateFromEntry(loser, StatusWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScopeSet) reservedIssue(scope string) Issue {
|
||||
if s.issueMessages.Reserved != nil {
|
||||
return s.issueMessages.Reserved(scope)
|
||||
}
|
||||
return Issue{
|
||||
Code: "app_cli_scope_reserved",
|
||||
Message: fmt.Sprintf("CLI scope %q is reserved.", scope),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScopeSet) conflictIssue(scope string, winnerAppID string) Issue {
|
||||
if s.issueMessages.Conflict != nil {
|
||||
return s.issueMessages.Conflict(scope, winnerAppID)
|
||||
}
|
||||
return Issue{
|
||||
Code: "app_cli_scope_conflict",
|
||||
Message: fmt.Sprintf("CLI scope %q is already provided by app %q.", scope, winnerAppID),
|
||||
}
|
||||
}
|
||||
|
||||
func stateFromEntry(entry *scopeEntry, status Status) State {
|
||||
return State{
|
||||
Status: status,
|
||||
Scope: entry.Scope,
|
||||
Active: status == StatusActive,
|
||||
Issues: append([]Issue(nil), entry.issues...),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneState(state State) State {
|
||||
state.Issues = append([]Issue(nil), state.Issues...)
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScopeSetConflictKeepsDeterministicWinner(t *testing.T) {
|
||||
scopeSet := NewScopeSet(ScopeSetOptions{})
|
||||
scopeSet.Upsert(RegisteredApp{
|
||||
AppID: "z-app",
|
||||
Scope: "automation",
|
||||
Commands: []Command{{
|
||||
Capability: Capability{ID: "app.z-app.automation.run"},
|
||||
}},
|
||||
})
|
||||
scopeSet.Upsert(RegisteredApp{
|
||||
AppID: "a-app",
|
||||
Scope: "automation",
|
||||
Commands: []Command{{
|
||||
Capability: Capability{ID: "app.a-app.automation.run"},
|
||||
}},
|
||||
})
|
||||
|
||||
capabilities := scopeSet.Capabilities()
|
||||
if len(capabilities) != 1 || capabilities[0].ID != "app.a-app.automation.run" {
|
||||
t.Fatalf("capabilities = %#v", capabilities)
|
||||
}
|
||||
if state := scopeSet.State("z-app"); state.Status != StatusWarning || len(state.Issues) != 1 {
|
||||
t.Fatalf("loser state = %#v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeSetReservedScopeWarningsDoNotExposeCommands(t *testing.T) {
|
||||
scopeSet := NewScopeSet(ScopeSetOptions{ReservedScopes: map[string]struct{}{"agent": {}}})
|
||||
state := scopeSet.Upsert(RegisteredApp{
|
||||
AppID: "agent-app",
|
||||
Scope: "agent",
|
||||
Commands: []Command{{
|
||||
Capability: Capability{ID: "app.agent-app.agent.run"},
|
||||
}},
|
||||
})
|
||||
|
||||
if state.Status != StatusWarning || state.Active {
|
||||
t.Fatalf("state = %#v", state)
|
||||
}
|
||||
if capabilities := scopeSet.Capabilities(); len(capabilities) != 0 {
|
||||
t.Fatalf("capabilities = %#v", capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeSetHidesIntegrationCommandsFromDefaultCapabilities(t *testing.T) {
|
||||
scopeSet := NewScopeSet(ScopeSetOptions{})
|
||||
scopeSet.Upsert(RegisteredApp{
|
||||
AppID: "automation-app",
|
||||
Scope: "automation",
|
||||
Commands: []Command{
|
||||
{Capability: Capability{ID: "app.automation.automation.list", Visibility: CommandVisibilityPublic}},
|
||||
{Capability: Capability{ID: "app.automation.automation.internal-sync", Visibility: CommandVisibilityIntegration}},
|
||||
},
|
||||
})
|
||||
|
||||
capabilities := scopeSet.Capabilities()
|
||||
if len(capabilities) != 1 || capabilities[0].ID != "app.automation.automation.list" {
|
||||
t.Fatalf("capabilities = %#v", capabilities)
|
||||
}
|
||||
|
||||
capabilities = scopeSet.Capabilities(CapabilityListOptions{IncludeIntegration: true})
|
||||
if len(capabilities) != 2 {
|
||||
t.Fatalf("capabilities with integration = %#v", capabilities)
|
||||
}
|
||||
if _, command, ok := scopeSet.Command("app.automation.automation.internal-sync"); !ok || command.Capability.ID == "" {
|
||||
t.Fatalf("integration command lookup failed: %#v", command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": 1,
|
||||
"commands": {
|
||||
"managed-model.grant.exchange": {
|
||||
"argv": ["managed-model", "grant", "exchange", "--input-json", "-"],
|
||||
"input": {
|
||||
"contextToken": "context-test",
|
||||
"grantCode": "grant-test",
|
||||
"nonce": "nonce-test",
|
||||
"state": "state-test"
|
||||
},
|
||||
"output": {
|
||||
"grantRef": "grant-ref-test",
|
||||
"providers": ["openai"],
|
||||
"models": [
|
||||
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
|
||||
],
|
||||
"expiresAt": "2026-07-12T00:10:00Z"
|
||||
}
|
||||
},
|
||||
"managed-model.models": {
|
||||
"argv": ["managed-model", "models", "--input-json", "-"],
|
||||
"input": { "grantRef": "grant-ref-test" },
|
||||
"output": {
|
||||
"models": [
|
||||
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
|
||||
],
|
||||
"expiresAt": "2026-07-12T00:10:00Z"
|
||||
}
|
||||
},
|
||||
"managed-model.credential": {
|
||||
"argv": ["managed-model", "credential", "--input-json", "-"],
|
||||
"input": {
|
||||
"grantRef": "grant-ref-test",
|
||||
"provider": "openai",
|
||||
"model": "gpt-test",
|
||||
"capability": "agent"
|
||||
},
|
||||
"output": {
|
||||
"credential": {
|
||||
"provider": "openai",
|
||||
"apiKey": "fixture-only-key",
|
||||
"models": [
|
||||
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
|
||||
]
|
||||
},
|
||||
"models": [
|
||||
{ "id": "gpt-test", "name": "GPT Test", "provider": "openai" }
|
||||
],
|
||||
"expiresAt": "2026-07-12T00:05:00Z"
|
||||
}
|
||||
},
|
||||
"managed-model.revoke": {
|
||||
"argv": ["managed-model", "revoke", "--input-json", "-"],
|
||||
"input": { "grantRef": "grant-ref-test" },
|
||||
"output": { "ok": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
type OutputMode string
|
||||
|
||||
const (
|
||||
OutputModeTable OutputMode = "table"
|
||||
OutputModeJSON OutputMode = "json"
|
||||
)
|
||||
|
||||
type CommandVisibility string
|
||||
|
||||
type CommandExecutionMode string
|
||||
|
||||
const CommandExecutionModeWait CommandExecutionMode = "wait"
|
||||
|
||||
type CommandExecution struct {
|
||||
Mode CommandExecutionMode
|
||||
}
|
||||
|
||||
type TableColumn struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
|
||||
type TableOutput struct {
|
||||
Columns []TableColumn
|
||||
}
|
||||
|
||||
type CapabilityOutput struct {
|
||||
DefaultMode OutputMode
|
||||
JSON bool
|
||||
Table *TableOutput
|
||||
}
|
||||
|
||||
type CapabilitySourceKind string
|
||||
|
||||
const (
|
||||
CapabilitySourceApp CapabilitySourceKind = "app"
|
||||
)
|
||||
|
||||
type CapabilitySource struct {
|
||||
Kind CapabilitySourceKind
|
||||
AppID string
|
||||
AppName string
|
||||
IconURL string
|
||||
CLIDescription string
|
||||
AppDescription string
|
||||
DocumentationFile string
|
||||
DocumentationPath string
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
ID string
|
||||
Path []string
|
||||
Summary string
|
||||
Description string
|
||||
Visibility CommandVisibility
|
||||
InputSchema map[string]any
|
||||
Output CapabilityOutput
|
||||
Execution *CommandExecution
|
||||
HandlerTimeoutMs int
|
||||
Source CapabilitySource
|
||||
}
|
||||
|
||||
type CommandContinuationState string
|
||||
|
||||
const CommandContinuationStatePending CommandContinuationState = "pending"
|
||||
|
||||
const (
|
||||
MinContinuationRetryAfterMs = 250
|
||||
MaxContinuationRetryAfterMs = 60000
|
||||
)
|
||||
|
||||
type CommandContinuation struct {
|
||||
State CommandContinuationState
|
||||
RetryAfterMs int
|
||||
}
|
||||
|
||||
type CommandOutput struct {
|
||||
Kind OutputMode
|
||||
Columns []TableColumn
|
||||
Rows []map[string]any
|
||||
Value map[string]any
|
||||
Text string
|
||||
Continuation *CommandContinuation
|
||||
}
|
||||
|
||||
type InputWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
Capability Capability
|
||||
Manifest ManifestCommand
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type CommandBuildOptions struct {
|
||||
AppID string
|
||||
AppName string
|
||||
IconURL string
|
||||
AppDescription string
|
||||
DocumentationFile string
|
||||
DocumentationPath string
|
||||
}
|
||||
Reference in New Issue
Block a user