feat: add dragonscale SDK, eval instrumentation, and opsctl
- pkg/dragonscale/sdk: Service abstraction for agent ops, versioning, embedded workspace; used by CLI commands - pkg/eval/instrumentation: Model and trace instrumentation for eval harness - internal/opsctl: Ops automation (app, runner, format, tasks) - cmd/opsctl: Standalone opsctl binary for automation workflows Register help task in opsctl so Makefile phony targets (including help) are available in opsctl --help output.
This commit is contained in:
parent
946360afce
commit
7f6298e240
14 changed files with 3167 additions and 0 deletions
215
cmd/opsctl/main.go
Normal file
215
cmd/opsctl/main.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/app"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/format"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/tasks"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exitCodeError struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *exitCodeError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectEnvFromHost(keys []string) map[string]string {
|
||||||
|
extra := make(map[string]string, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := os.LookupEnv(key); ok {
|
||||||
|
extra[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage(w io.Writer, a *app.App, appName string) {
|
||||||
|
_, _ = fmt.Fprintf(w, "Usage: %s [global flags] <command> [args]\n\n", appName)
|
||||||
|
_, _ = fmt.Fprintf(w, "Global flags:\n")
|
||||||
|
_, _ = fmt.Fprintln(w, " --format string output mode: text, json, raw (default text)")
|
||||||
|
_, _ = fmt.Fprintln(w, " --json shortcut for --format json")
|
||||||
|
_, _ = fmt.Fprintln(w, " --raw shortcut for --format raw")
|
||||||
|
_, _ = fmt.Fprintln(w, " --no-color disable color output")
|
||||||
|
_, _ = fmt.Fprintln(w, " --quiet suppress non-error output")
|
||||||
|
_, _ = fmt.Fprintln(w, " --root string repository root path (default current directory)")
|
||||||
|
_, _ = fmt.Fprintln(w, " --cwd string working directory for command execution")
|
||||||
|
_, _ = fmt.Fprintln(w, " --timeout value command timeout (ex: 2m, 30s)")
|
||||||
|
_, _ = fmt.Fprintln(w)
|
||||||
|
_, _ = fmt.Fprintln(w, "Tasks:")
|
||||||
|
for _, line := range a.TaskHelp() {
|
||||||
|
_, _ = fmt.Fprintln(w, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutputMode(raw, json bool, mode string) (format.OutputMode, error) {
|
||||||
|
if raw {
|
||||||
|
return format.OutputRaw, nil
|
||||||
|
}
|
||||||
|
if json {
|
||||||
|
return format.OutputJSON, nil
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = string(format.OutputText)
|
||||||
|
}
|
||||||
|
switch format.OutputMode(mode) {
|
||||||
|
case format.OutputText, format.OutputJSON, format.OutputRaw:
|
||||||
|
return format.OutputMode(mode), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid --format value %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOpsApp(root string) *app.App {
|
||||||
|
rootPath := app.EnsureRoot(root)
|
||||||
|
ops := app.New(runner.OSRunner{}, rootPath)
|
||||||
|
for _, t := range tasks.NewRegistry(rootPath) {
|
||||||
|
ops.Register(t)
|
||||||
|
}
|
||||||
|
ops.Register(tasks.NewHelpTask(ops))
|
||||||
|
return ops
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRunEnvKeys() []string {
|
||||||
|
return []string{
|
||||||
|
"GO",
|
||||||
|
"GOFLAGS",
|
||||||
|
"CGO_ENABLED",
|
||||||
|
"GOOS",
|
||||||
|
"GOARCH",
|
||||||
|
"DRAGONSCALE_EVAL_HOST_HOME",
|
||||||
|
"DRAGONSCALE_EVAL_BASE_CONFIG",
|
||||||
|
"DRAGONSCALE_EVAL_CONFIG",
|
||||||
|
"DRAGONSCALE_EVAL_DEBUG",
|
||||||
|
"DEVCONTAINER_EXEC",
|
||||||
|
"PLATFORM",
|
||||||
|
"ARCH",
|
||||||
|
"BINARY_NAME",
|
||||||
|
"BUILD_DIR",
|
||||||
|
"VERSION",
|
||||||
|
"FANTASY_VERSION",
|
||||||
|
"NAME",
|
||||||
|
"ARGS",
|
||||||
|
"WORKSPACE_DIR",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOpsctlCommand(stdout, stderr io.Writer, args []string) *cobra.Command {
|
||||||
|
var (
|
||||||
|
formatValue string
|
||||||
|
jsonMode bool
|
||||||
|
rawMode bool
|
||||||
|
noColor bool
|
||||||
|
quiet bool
|
||||||
|
cwd string
|
||||||
|
rootFlag string
|
||||||
|
timeout time.Duration
|
||||||
|
)
|
||||||
|
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "opsctl [global flags] <command> [args]",
|
||||||
|
Short: "opsctl is a thin CLI wrapper over repository operations",
|
||||||
|
Long: "opsctl is a thin CLI wrapper over repository operations.",
|
||||||
|
RunE: func(cobraCmd *cobra.Command, cmdArgs []string) error {
|
||||||
|
mode, err := parseOutputMode(rawMode, jsonMode, formatValue)
|
||||||
|
if err != nil {
|
||||||
|
return &exitCodeError{code: 2, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedRoot := app.EnsureRoot(rootFlag)
|
||||||
|
ops := newOpsApp(resolvedRoot)
|
||||||
|
|
||||||
|
if len(cmdArgs) == 0 || cmdArgs[0] == "help" || cmdArgs[0] == "--help" || cmdArgs[0] == "-h" {
|
||||||
|
printUsage(cobraCmd.OutOrStdout(), ops, cobraCmd.Root().Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
taskName := strings.TrimSpace(cmdArgs[0])
|
||||||
|
if taskName == "" {
|
||||||
|
printUsage(cobraCmd.OutOrStdout(), ops, cobraCmd.Root().Name())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
taskArgs := cmdArgs[1:]
|
||||||
|
|
||||||
|
if !ops.HasTask(taskName) {
|
||||||
|
return &exitCodeError{code: 2, err: fmt.Errorf("unknown command: %s", taskName)}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &app.Context{
|
||||||
|
Root: resolvedRoot,
|
||||||
|
Cwd: cwd,
|
||||||
|
Format: mode,
|
||||||
|
Quiet: quiet,
|
||||||
|
NoColor: noColor,
|
||||||
|
Timeout: timeout,
|
||||||
|
ExtraEnv: collectEnvFromHost(defaultRunEnvKeys()),
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, runErr := ops.Run(context.Background(), taskName, taskArgs, ctx)
|
||||||
|
if err := format.Render(cobraCmd.OutOrStdout(), mode, result, ctx.Quiet); err != nil {
|
||||||
|
return &exitCodeError{code: 1, err: fmt.Errorf("failed to render output: %v", err)}
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
return &exitCodeError{code: 1, err: runErr}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
root.SetOut(stdout)
|
||||||
|
root.SetErr(stderr)
|
||||||
|
root.SetArgs(args)
|
||||||
|
|
||||||
|
root.PersistentFlags().StringVar(&formatValue, "format", string(format.OutputText), "output mode: text, json, raw")
|
||||||
|
root.PersistentFlags().BoolVar(&jsonMode, "json", false, "shortcut for --format json")
|
||||||
|
root.PersistentFlags().BoolVar(&rawMode, "raw", false, "shortcut for --format raw")
|
||||||
|
root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output (reserved)")
|
||||||
|
root.PersistentFlags().BoolVar(&quiet, "quiet", false, "suppress non-error output")
|
||||||
|
root.PersistentFlags().StringVar(&rootFlag, "root", "", "repository root")
|
||||||
|
root.PersistentFlags().StringVar(&cwd, "cwd", "", "working directory for command execution")
|
||||||
|
root.PersistentFlags().DurationVar(&timeout, "timeout", 0, "command timeout")
|
||||||
|
|
||||||
|
root.SetHelpFunc(func(cobraCmd *cobra.Command, _ []string) {
|
||||||
|
if _, err := parseOutputMode(rawMode, jsonMode, formatValue); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(cobraCmd.ErrOrStderr(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rootPath := app.EnsureRoot(rootFlag)
|
||||||
|
printUsage(cobraCmd.OutOrStdout(), newOpsApp(rootPath), cobraCmd.Root().Name())
|
||||||
|
})
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
command := buildOpsctlCommand(stdout, stderr, args)
|
||||||
|
if err := command.Execute(); err != nil {
|
||||||
|
if codeErr, ok := err.(*exitCodeError); ok {
|
||||||
|
_, _ = fmt.Fprintln(stderr, codeErr.err)
|
||||||
|
return codeErr.code
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(stderr, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if code := run(os.Args[1:], os.Stdout, os.Stderr); code != 0 {
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
cmd/opsctl/main_test.go
Normal file
192
cmd/opsctl/main_test.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOutputMode(t *testing.T) {
|
||||||
|
mode, err := parseOutputMode(false, false, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "text", string(mode))
|
||||||
|
|
||||||
|
mode, err = parseOutputMode(true, false, "text")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "raw", string(mode))
|
||||||
|
|
||||||
|
mode, err = parseOutputMode(false, true, "text")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "json", string(mode))
|
||||||
|
|
||||||
|
_, err = parseOutputMode(false, false, "invalid")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPrintsUsageWithoutArgs(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{}, out, errOut)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
require.Contains(t, out.String(), "Usage:")
|
||||||
|
require.Contains(t, out.String(), "Tasks:")
|
||||||
|
require.Equal(t, "", errOut.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownCommand(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{"not-a-real-command"}, out, errOut)
|
||||||
|
require.Equal(t, 2, code)
|
||||||
|
require.Contains(t, errOut.String(), "unknown command: not-a-real-command")
|
||||||
|
require.Equal(t, "", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunInvalidFormat(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{"--format=bad", "help"}, out, errOut)
|
||||||
|
require.Equal(t, 2, code)
|
||||||
|
require.Contains(t, errOut.String(), "invalid --format value \"bad\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelpAlias(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{"--help"}, out, errOut)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
require.Contains(t, out.String(), "Usage:")
|
||||||
|
require.Equal(t, "", errOut.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHelpIncludesAllAlias(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{"help"}, out, errOut)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
require.Equal(t, "", errOut.String())
|
||||||
|
require.Contains(t, out.String(), "all")
|
||||||
|
require.Contains(t, out.String(), "build")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakefilePhonyTargetsAreAvailableInOpsctl(t *testing.T) {
|
||||||
|
makeTargets := parseMakefilePhonyTargets(t)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
code := run([]string{"--help"}, out, errOut)
|
||||||
|
require.Equal(t, 0, code)
|
||||||
|
require.Equal(t, "", errOut.String())
|
||||||
|
|
||||||
|
tasks := parseOpsctlHelpTasks(t, out.String())
|
||||||
|
for _, target := range makeTargets {
|
||||||
|
require.Contains(t, tasks, target, "Makefile phony target missing from opsctl help: %s", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMakefilePhonyTargets(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
makefile, err := findProjectMakefile()
|
||||||
|
require.NoError(t, err)
|
||||||
|
raw, err := os.ReadFile(makefile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
lines := bytes.Split(raw, []byte("\n"))
|
||||||
|
|
||||||
|
var targets []string
|
||||||
|
inPhony := false
|
||||||
|
for _, b := range lines {
|
||||||
|
line := string(b)
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, ".PHONY:") {
|
||||||
|
inPhony = true
|
||||||
|
line = strings.TrimPrefix(trimmed, ".PHONY:")
|
||||||
|
} else if !inPhony {
|
||||||
|
continue
|
||||||
|
} else if strings.HasPrefix(trimmed, "#") {
|
||||||
|
inPhony = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inPhony {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(line, "\\") {
|
||||||
|
line = strings.TrimSuffix(line, "\\")
|
||||||
|
} else {
|
||||||
|
inPhony = false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range strings.Fields(line) {
|
||||||
|
if target == "\\" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(string(b), "\\") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func findProjectMakefile() (string, error) {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
candidate := filepath.Join(dir, "Makefile")
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpsctlHelpTasks(t *testing.T, output string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var tasks []string
|
||||||
|
inTasks := false
|
||||||
|
for _, line := range bytes.Split([]byte(output), []byte("\n")) {
|
||||||
|
text := string(line)
|
||||||
|
if strings.HasPrefix(text, "Tasks:") {
|
||||||
|
inTasks = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTasks {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "environment:") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" || strings.HasPrefix(text, "available") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(text)
|
||||||
|
if len(parts) == 0 || strings.HasPrefix(parts[0], "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tasks = append(tasks, parts[0])
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
193
internal/opsctl/app/app.go
Normal file
193
internal/opsctl/app/app.go
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/format"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Task interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Run(ctx context.Context, r runner.Runner, io *Context) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Task string
|
||||||
|
Command string
|
||||||
|
ExitCode int
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Err string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Root string
|
||||||
|
Cwd string
|
||||||
|
Format format.OutputMode
|
||||||
|
Quiet bool
|
||||||
|
NoColor bool
|
||||||
|
Timeout time.Duration
|
||||||
|
ExtraEnv map[string]string
|
||||||
|
Argv []string
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
Debug bool
|
||||||
|
RunID string
|
||||||
|
DevContainer bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
tasks map[string]Task
|
||||||
|
order []string
|
||||||
|
root string
|
||||||
|
runner runner.Runner
|
||||||
|
clock func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(r runner.Runner, root string) *App {
|
||||||
|
return &App{
|
||||||
|
tasks: make(map[string]Task),
|
||||||
|
root: root,
|
||||||
|
runner: r,
|
||||||
|
clock: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Register(task Task) {
|
||||||
|
name := task.Name()
|
||||||
|
a.tasks[name] = task
|
||||||
|
a.order = append(a.order, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Task(name string) (Task, bool) {
|
||||||
|
task, ok := a.tasks[name]
|
||||||
|
return task, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) List() []Task {
|
||||||
|
taskList := make([]Task, 0, len(a.tasks))
|
||||||
|
seen := make(map[string]bool, len(a.tasks))
|
||||||
|
for _, name := range a.order {
|
||||||
|
task, ok := a.tasks[name]
|
||||||
|
if !ok || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
taskList = append(taskList, task)
|
||||||
|
}
|
||||||
|
for name, task := range a.tasks {
|
||||||
|
if seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
taskList = append(taskList, task)
|
||||||
|
}
|
||||||
|
sort.Slice(taskList, func(i, j int) bool {
|
||||||
|
return taskList[i].Name() < taskList[j].Name()
|
||||||
|
})
|
||||||
|
return taskList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) HasTask(name string) bool {
|
||||||
|
_, ok := a.tasks[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Run(ctx context.Context, name string, args []string, outCtx *Context) (format.TaskResult, error) {
|
||||||
|
task, ok := a.tasks[name]
|
||||||
|
if !ok {
|
||||||
|
return format.TaskResult{}, fmt.Errorf("unknown task: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxWithTimeout := ctx
|
||||||
|
start := a.clock()
|
||||||
|
if outCtx.Timeout > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctxWithTimeout, cancel = context.WithTimeout(ctx, outCtx.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCtx := *outCtx
|
||||||
|
taskCtx.Cwd = nonEmpty(taskCtx.Cwd, taskCtx.Root)
|
||||||
|
taskCtx.Argv = args
|
||||||
|
|
||||||
|
res, err := task.Run(ctxWithTimeout, a.runner, &taskCtx)
|
||||||
|
finish := a.clock()
|
||||||
|
success := err == nil && res.Err == ""
|
||||||
|
tr := format.TaskResult{
|
||||||
|
Task: name,
|
||||||
|
Command: res.Command,
|
||||||
|
ExitCode: res.ExitCode,
|
||||||
|
Stdout: res.Stdout,
|
||||||
|
Stderr: res.Stderr,
|
||||||
|
Error: res.Err,
|
||||||
|
NoColor: outCtx.NoColor,
|
||||||
|
StartedAt: start,
|
||||||
|
EndedAt: finish,
|
||||||
|
DurationMS: int64(finish.Sub(start) / time.Millisecond),
|
||||||
|
Success: success,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if strings.TrimSpace(res.Err) != "" {
|
||||||
|
tr.Error = res.Err
|
||||||
|
} else {
|
||||||
|
tr.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tr.Error != "" && tr.ExitCode == 0 {
|
||||||
|
tr.ExitCode = 1
|
||||||
|
}
|
||||||
|
return tr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) formatTaskHelp() []string {
|
||||||
|
lines := make([]string, 0, len(a.tasks)+1)
|
||||||
|
lines = append(lines, "available tasks:")
|
||||||
|
for _, t := range a.List() {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %-20s %s", t.Name(), t.Description()))
|
||||||
|
}
|
||||||
|
lines = append(lines, "\nenvironment:")
|
||||||
|
lines = append(lines, " DRAGONSCALE_EVAL_HOST_HOME")
|
||||||
|
lines = append(lines, " DRAGONSCALE_EVAL_BASE_CONFIG")
|
||||||
|
lines = append(lines, " DRAGONSCALE_EVAL_CONFIG")
|
||||||
|
lines = append(lines, " DEVCONTAINER_EXEC")
|
||||||
|
lines = append(lines, " DRAGONSCALE_EVAL_DEBUG")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) TaskHelp() []string {
|
||||||
|
return a.formatTaskHelp()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRoot() string {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err == nil {
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureRoot(root string) string {
|
||||||
|
clean := filepath.Clean(root)
|
||||||
|
if clean == "." || clean == "" {
|
||||||
|
return DefaultRoot()
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
114
internal/opsctl/app/app_test.go
Normal file
114
internal/opsctl/app/app_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskRegistryAndDispatch(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
ops := New(runner.OSRunner{}, root)
|
||||||
|
|
||||||
|
task := &captureTask{
|
||||||
|
result: Result{
|
||||||
|
Task: "demo",
|
||||||
|
Command: "echo hi",
|
||||||
|
ExitCode: 0,
|
||||||
|
Stdout: "ok",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ops.Register(task)
|
||||||
|
require.True(t, ops.HasTask("demo"))
|
||||||
|
require.False(t, ops.HasTask("missing"))
|
||||||
|
|
||||||
|
tr, err := ops.Run(context.Background(), "demo", []string{"--flag", "value"}, &Context{Cwd: root})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, task.called)
|
||||||
|
require.Equal(t, []string{"--flag", "value"}, task.receivedArgs)
|
||||||
|
require.Equal(t, root, task.receivedCtx.Cwd)
|
||||||
|
require.Equal(t, "demo", tr.Task)
|
||||||
|
require.Equal(t, 0, tr.ExitCode)
|
||||||
|
require.Equal(t, "ok", tr.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPropagatesTaskError(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
ops := New(runner.OSRunner{}, root)
|
||||||
|
taskErr := errors.New("boom")
|
||||||
|
resultErr := Result{Task: "failed", Command: "false", ExitCode: 2, Err: taskErr.Error()}
|
||||||
|
ops.Register(&captureTask{result: resultErr})
|
||||||
|
|
||||||
|
tr, err := ops.Run(context.Background(), "failed", []string{}, &Context{Root: root})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, "failed", tr.Task)
|
||||||
|
require.Equal(t, 2, tr.ExitCode)
|
||||||
|
require.Equal(t, "boom", tr.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAppliesTimeoutContext(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
ops := New(runner.OSRunner{}, root)
|
||||||
|
called := false
|
||||||
|
blocking := &captureTask{
|
||||||
|
result: Result{Task: "slow", ExitCode: 0},
|
||||||
|
withContext: func(ctx context.Context) {
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, time.Now().Before(deadline))
|
||||||
|
called = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ops.Register(blocking)
|
||||||
|
|
||||||
|
_, err := ops.Run(context.Background(), "slow", nil, &Context{Root: root, Timeout: 250 * time.Millisecond})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, called)
|
||||||
|
require.True(t, blocking.called)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsStableWithoutDuplicateRegistration(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
ops := New(runner.OSRunner{}, root)
|
||||||
|
ops.Register(&captureTask{result: Result{Task: "dup", ExitCode: 0}})
|
||||||
|
ops.Register(&captureTask{result: Result{Task: "dup", ExitCode: 0}})
|
||||||
|
|
||||||
|
items := ops.List()
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
require.Equal(t, "dup", items[0].Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureTask struct {
|
||||||
|
called bool
|
||||||
|
result Result
|
||||||
|
receivedArgs []string
|
||||||
|
receivedCtx *Context
|
||||||
|
withContext func(context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *captureTask) Name() string {
|
||||||
|
return t.result.Task
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *captureTask) Description() string {
|
||||||
|
return "capture task"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *captureTask) Run(ctx context.Context, _ runner.Runner, c *Context) (Result, error) {
|
||||||
|
t.called = true
|
||||||
|
t.receivedArgs = append([]string{}, c.Argv...)
|
||||||
|
if c.Cwd != "" {
|
||||||
|
t.receivedCtx = &Context{Root: c.Root, Cwd: c.Cwd, Argv: append([]string{}, c.Argv...), Quiet: c.Quiet, Timeout: c.Timeout}
|
||||||
|
}
|
||||||
|
if t.withContext != nil {
|
||||||
|
t.withContext(ctx)
|
||||||
|
}
|
||||||
|
if t.result.Err != "" {
|
||||||
|
return t.result, errors.New(t.result.Err)
|
||||||
|
}
|
||||||
|
return t.result, nil
|
||||||
|
}
|
||||||
121
internal/opsctl/format/format.go
Normal file
121
internal/opsctl/format/format.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package format
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutputMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutputText OutputMode = "text"
|
||||||
|
OutputJSON OutputMode = "json"
|
||||||
|
OutputRaw OutputMode = "raw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskResult struct {
|
||||||
|
Task string `json:"task"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
DurationMS int64 `json:"duration_ms"`
|
||||||
|
Stdout string `json:"stdout,omitempty"`
|
||||||
|
Stderr string `json:"stderr,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
EndedAt time.Time `json:"ended_at"`
|
||||||
|
Env []string `json:"env,omitempty"`
|
||||||
|
Mode OutputMode `json:"mode"`
|
||||||
|
NoColor bool `json:"no_color,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskResult(name string, cmd string, result runner.CommandResult, startedAt time.Time, err error) TaskResult {
|
||||||
|
tr := TaskResult{
|
||||||
|
Task: name,
|
||||||
|
Command: cmd,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
DurationMS: int64(result.Duration / time.Millisecond),
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
EndedAt: startedAt.Add(result.Duration),
|
||||||
|
Mode: OutputText,
|
||||||
|
Success: err == nil && result.ExitCode == 0,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
tr.Error = err.Error()
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(w io.Writer, mode OutputMode, res TaskResult, quiet bool) error {
|
||||||
|
res.Mode = mode
|
||||||
|
switch mode {
|
||||||
|
case OutputJSON:
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(res)
|
||||||
|
case OutputRaw:
|
||||||
|
if quiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefix := statusPrefix(!res.NoColor, res.Success)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s %s (%dms)\n", prefix, res.Task, res.DurationMS)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
if quiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status := "ok"
|
||||||
|
if !res.Success {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
status = formatStatus(!res.NoColor, res.Success)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s: %s (exit=%d, duration=%dms)\n", res.Task, status, res.ExitCode, res.DurationMS)
|
||||||
|
if res.Error != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, " error: %s\n", res.Error)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(res.Stdout) != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, " stdout: %s\n", res.Stdout)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(res.Stderr) != "" {
|
||||||
|
_, _ = fmt.Fprintf(w, " stderr: %s\n", res.Stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatStatus(colored bool, success bool) string {
|
||||||
|
if !colored {
|
||||||
|
if !success {
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
return colorize("ok", "32")
|
||||||
|
}
|
||||||
|
return colorize("failed", "31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusPrefix(colored bool, success bool) string {
|
||||||
|
if !colored {
|
||||||
|
if !success {
|
||||||
|
return "[err]"
|
||||||
|
}
|
||||||
|
return "[ok]"
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
return colorize("[ok]", "32")
|
||||||
|
}
|
||||||
|
return colorize("[err]", "31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorize(text string, colorCode string) string {
|
||||||
|
return "\x1b[" + colorCode + "m" + text + "\x1b[0m"
|
||||||
|
}
|
||||||
61
internal/opsctl/format/format_test.go
Normal file
61
internal/opsctl/format/format_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package format
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderJSONIncludesTaskAndError(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
result := TaskResult{
|
||||||
|
Task: "build",
|
||||||
|
Command: "go build",
|
||||||
|
ExitCode: 1,
|
||||||
|
Error: "failed",
|
||||||
|
Success: false,
|
||||||
|
StartedAt: time.Unix(1, 0),
|
||||||
|
EndedAt: time.Unix(2, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, Render(buf, OutputJSON, result, false))
|
||||||
|
decoded := TaskResult{}
|
||||||
|
require.NoError(t, json.NewDecoder(buf).Decode(&decoded))
|
||||||
|
require.Equal(t, "build", decoded.Task)
|
||||||
|
require.Equal(t, "failed", decoded.Error)
|
||||||
|
require.Equal(t, 1, decoded.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTextIncludesDetails(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
result := TaskResult{
|
||||||
|
Task: "lint",
|
||||||
|
ExitCode: 0,
|
||||||
|
DurationMS: 123,
|
||||||
|
Stdout: "ok\n",
|
||||||
|
NoColor: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, Render(buf, OutputText, result, false))
|
||||||
|
output := buf.String()
|
||||||
|
require.Contains(t, output, "lint")
|
||||||
|
require.Contains(t, output, "exit=0")
|
||||||
|
require.Contains(t, output, "stdout: ok")
|
||||||
|
require.NotContains(t, output, "\x1b[")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRawHonorQuiet(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
result := TaskResult{Task: "run", ExitCode: 0, DurationMS: 42, Success: true}
|
||||||
|
require.NoError(t, Render(buf, OutputRaw, result, true))
|
||||||
|
require.Equal(t, "", buf.String())
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
require.NoError(t, Render(buf, OutputRaw, result, false))
|
||||||
|
require.True(t, strings.Contains(buf.String(), "run"))
|
||||||
|
require.True(t, strings.Contains(buf.String(), "(42ms)"))
|
||||||
|
}
|
||||||
96
internal/opsctl/runner/runner.go
Normal file
96
internal/opsctl/runner/runner.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommandSpec struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
Dir string
|
||||||
|
Env []string
|
||||||
|
InheritOutput bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommandResult struct {
|
||||||
|
Command string
|
||||||
|
ExitCode int
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Duration time.Duration
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runner interface {
|
||||||
|
Run(ctx context.Context, spec CommandSpec) (CommandResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OSRunner struct{}
|
||||||
|
|
||||||
|
func (r OSRunner) Run(ctx context.Context, spec CommandSpec) (CommandResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
cmd := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
||||||
|
if spec.Dir != "" {
|
||||||
|
cmd.Dir = spec.Dir
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Env = append(os.Environ(), spec.Env...)
|
||||||
|
|
||||||
|
var stdoutBuf bytes.Buffer
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
if spec.InheritOutput {
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
} else {
|
||||||
|
cmd.Stdout = &stdoutBuf
|
||||||
|
cmd.Stderr = &stderrBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
exitCode := 0
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := CommandResult{
|
||||||
|
Command: spec.Name,
|
||||||
|
ExitCode: exitCode,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
if !spec.InheritOutput {
|
||||||
|
result.Stdout = stdoutBuf.String()
|
||||||
|
result.Stderr = stderrBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("command failed: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeRunner struct {
|
||||||
|
Calls []CommandSpec
|
||||||
|
Result CommandResult
|
||||||
|
Handler func(CommandSpec) CommandResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FakeRunner) Run(_ context.Context, spec CommandSpec) (CommandResult, error) {
|
||||||
|
r.Calls = append(r.Calls, spec)
|
||||||
|
if r.Handler != nil {
|
||||||
|
res := r.Handler(spec)
|
||||||
|
return res, res.Err
|
||||||
|
}
|
||||||
|
return r.Result, r.Result.Err
|
||||||
|
}
|
||||||
55
internal/opsctl/runner/runner_test.go
Normal file
55
internal/opsctl/runner/runner_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeRunnerRecordsCalls(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
r := &FakeRunner{
|
||||||
|
Handler: func(spec CommandSpec) CommandResult {
|
||||||
|
called = true
|
||||||
|
return CommandResult{
|
||||||
|
Command: "ok",
|
||||||
|
ExitCode: 11,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.Run(context.Background(), CommandSpec{Name: "test", Args: []string{"--flag"}, Env: []string{"A=1"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, called)
|
||||||
|
require.Len(t, r.Calls, 1)
|
||||||
|
require.Equal(t, "test", r.Calls[0].Name)
|
||||||
|
require.Equal(t, []string{"--flag"}, r.Calls[0].Args)
|
||||||
|
require.Equal(t, []string{"A=1"}, r.Calls[0].Env)
|
||||||
|
require.Equal(t, 11, result.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRunnerPropagatesHandlerError(t *testing.T) {
|
||||||
|
r := &FakeRunner{
|
||||||
|
Handler: func(_ CommandSpec) CommandResult {
|
||||||
|
err := errors.New("boom")
|
||||||
|
return CommandResult{Command: "bad", ExitCode: -1, Err: err}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.Run(context.Background(), CommandSpec{Name: "bad"})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, "bad", result.Command)
|
||||||
|
require.Equal(t, -1, result.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOSRunnerReturnsCommandError(t *testing.T) {
|
||||||
|
r := OSRunner{}
|
||||||
|
result, err := r.Run(context.Background(), CommandSpec{Name: "sh", Args: []string{"-c", "echo stdout; echo stderr 1>&2; exit 3"}})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, "command failed: exit status 3", err.Error())
|
||||||
|
require.Equal(t, 3, result.ExitCode)
|
||||||
|
require.Equal(t, "stdout\n", result.Stdout)
|
||||||
|
require.Equal(t, "stderr\n", result.Stderr)
|
||||||
|
}
|
||||||
31
pkg/dragonscale/sdk/options.go
Normal file
31
pkg/dragonscale/sdk/options.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
type AgentOptions struct {
|
||||||
|
Message string
|
||||||
|
SessionKey string
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayOptions struct {
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type MigrateOptions struct {
|
||||||
|
DryRun bool
|
||||||
|
ConfigOnly bool
|
||||||
|
WorkspaceOnly bool
|
||||||
|
Force bool
|
||||||
|
Refresh bool
|
||||||
|
OpenClawHome string
|
||||||
|
DragonscaleHome string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CronAddOptions struct {
|
||||||
|
Name string
|
||||||
|
Message string
|
||||||
|
EveryMS *int64
|
||||||
|
Cron string
|
||||||
|
Deliver bool
|
||||||
|
To string
|
||||||
|
Channel string
|
||||||
|
}
|
||||||
199
pkg/dragonscale/sdk/sdk.go
Normal file
199
pkg/dragonscale/sdk/sdk.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
// Package sdk provides a reusable, IO-agnostic dragonscale service API.
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultLogo = "🐉"
|
||||||
|
|
||||||
|
// Service is the public entry point for CLI and other I/O adapters.
|
||||||
|
type Service struct {
|
||||||
|
Version string
|
||||||
|
GitCommit string
|
||||||
|
BuildTime string
|
||||||
|
GoVersion string
|
||||||
|
Logo string
|
||||||
|
EmbeddedFS fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIService contracts represent the public service API consumed by I/O adapters.
|
||||||
|
type CLIService interface {
|
||||||
|
VersionService
|
||||||
|
OnboardingService
|
||||||
|
AgentService
|
||||||
|
GatewayService
|
||||||
|
AuthService
|
||||||
|
CronService
|
||||||
|
SkillsService
|
||||||
|
SecretService
|
||||||
|
DaemonService
|
||||||
|
MemoryService
|
||||||
|
MigrationService
|
||||||
|
StatusService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a configured service instance.
|
||||||
|
func NewService(opts ...Option) *Service {
|
||||||
|
svc := &Service{
|
||||||
|
Version: "dev",
|
||||||
|
Logo: defaultLogo,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.GoVersion == "" {
|
||||||
|
svc.GoVersion = runtime.Version()
|
||||||
|
}
|
||||||
|
|
||||||
|
if svc.Logo == "" {
|
||||||
|
svc.Logo = defaultLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Service.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// WithVersion sets version metadata.
|
||||||
|
func WithVersion(version, gitCommit, buildTime, goVersion string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.Version = version
|
||||||
|
s.GitCommit = gitCommit
|
||||||
|
s.BuildTime = buildTime
|
||||||
|
s.GoVersion = goVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLogo sets the CLI logo glyph.
|
||||||
|
func WithLogo(logo string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.Logo = logo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEmbeddedFS sets the embedded template filesystem.
|
||||||
|
func WithEmbeddedFS(fsys fs.FS) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.EmbeddedFS = fsys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionString returns the version text with optional git commit suffix.
|
||||||
|
func (s *Service) VersionString() string {
|
||||||
|
v := s.Version
|
||||||
|
if v == "" {
|
||||||
|
v = "dev"
|
||||||
|
}
|
||||||
|
if s.GitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", s.GitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildInfo returns build metadata.
|
||||||
|
func (s *Service) BuildInfo() (build string, goVer string) {
|
||||||
|
build = s.BuildTime
|
||||||
|
goVer = s.GoVersion
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnboardingService defines onboarding operations.
|
||||||
|
type OnboardingService interface {
|
||||||
|
Onboard(context.Context, io.Reader, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentService defines interactive and one-shot agent operations.
|
||||||
|
type AgentService interface {
|
||||||
|
Agent(context.Context, io.Reader, io.Writer, AgentOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayService defines gateway startup and control operations.
|
||||||
|
type GatewayService interface {
|
||||||
|
Gateway(context.Context, io.Writer, GatewayOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionService defines version reporting operations.
|
||||||
|
type VersionService interface {
|
||||||
|
PrintVersion(context.Context, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusService defines status reporting operations.
|
||||||
|
type StatusService interface {
|
||||||
|
Status(context.Context, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationService defines migration operations.
|
||||||
|
type MigrationService interface {
|
||||||
|
Migrate(context.Context, MigrateOptions, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthService defines auth lifecycle operations.
|
||||||
|
type AuthService interface {
|
||||||
|
AuthLogin(context.Context, io.Reader, io.Writer, string, bool) error
|
||||||
|
AuthLogout(context.Context, io.Writer, string) error
|
||||||
|
AuthStatus(context.Context, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronService defines cron job operations.
|
||||||
|
type CronService interface {
|
||||||
|
CronList(context.Context, io.Writer) error
|
||||||
|
CronAdd(context.Context, io.Writer, CronAddOptions) error
|
||||||
|
CronRemove(context.Context, io.Writer, string) error
|
||||||
|
CronEnable(context.Context, io.Writer, string, bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillsService defines skill lifecycle operations.
|
||||||
|
type SkillsService interface {
|
||||||
|
SkillsList(context.Context, io.Writer) error
|
||||||
|
SkillsListBuiltin(context.Context, io.Writer) error
|
||||||
|
SkillsInstall(context.Context, io.Writer, string) error
|
||||||
|
SkillsInstallBuiltin(context.Context, io.Writer, string) error
|
||||||
|
SkillsRemove(context.Context, io.Writer, string) error
|
||||||
|
SkillsSearch(context.Context, io.Writer) error
|
||||||
|
SkillsShow(context.Context, io.Writer, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretService defines encrypted secret management operations.
|
||||||
|
type SecretService interface {
|
||||||
|
SecretInit(context.Context, io.Writer) error
|
||||||
|
SecretAdd(context.Context, io.Reader, io.Writer, string) error
|
||||||
|
SecretList(context.Context, io.Writer) error
|
||||||
|
SecretDelete(context.Context, io.Writer, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonService defines daemon operations.
|
||||||
|
type DaemonService interface {
|
||||||
|
DaemonStart(context.Context, io.Writer) error
|
||||||
|
DaemonStop(context.Context, io.Writer) error
|
||||||
|
DaemonStatus(context.Context, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryService defines memory system operations.
|
||||||
|
type MemoryService interface {
|
||||||
|
MemoryMigrateSessions(context.Context, io.Writer) error
|
||||||
|
MemoryDBStatus(context.Context, io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ VersionService = (*Service)(nil)
|
||||||
|
_ CLIService = (*Service)(nil)
|
||||||
|
_ OnboardingService = (*Service)(nil)
|
||||||
|
_ AgentService = (*Service)(nil)
|
||||||
|
_ GatewayService = (*Service)(nil)
|
||||||
|
_ StatusService = (*Service)(nil)
|
||||||
|
_ MigrationService = (*Service)(nil)
|
||||||
|
_ AuthService = (*Service)(nil)
|
||||||
|
_ CronService = (*Service)(nil)
|
||||||
|
_ SkillsService = (*Service)(nil)
|
||||||
|
_ SecretService = (*Service)(nil)
|
||||||
|
_ DaemonService = (*Service)(nil)
|
||||||
|
_ MemoryService = (*Service)(nil)
|
||||||
|
)
|
||||||
1003
pkg/dragonscale/sdk/service_ops.go
Normal file
1003
pkg/dragonscale/sdk/service_ops.go
Normal file
File diff suppressed because it is too large
Load diff
681
pkg/dragonscale/sdk/service_ops_extras.go
Normal file
681
pkg/dragonscale/sdk/service_ops_extras.go
Normal file
|
|
@ -0,0 +1,681 @@
|
||||||
|
package sdk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/config"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/itr"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/memory"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/memory/delegate"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/security"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/security/securebus"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/skills"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) SkillsList(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
cfgDir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsPath, err := config.SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := skills.NewSkillsLoader(skillsPath, filepath.Join(cfgDir, "skills"), filepath.Join(cfgDir, "skills"))
|
||||||
|
|
||||||
|
skillInfos := registry.ListSkills()
|
||||||
|
if len(skillInfos) == 0 {
|
||||||
|
fmt.Fprintln(out, "No skills installed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(skillInfos, func(i, j int) bool {
|
||||||
|
return skillInfos[i].Name < skillInfos[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, info := range skillInfos {
|
||||||
|
content, ok := registry.LoadSkill(info.Name)
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(out, `%s: unable to load (not found)\n`, info.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
description := strings.TrimSpace(skillDescriptionFromMarkdownData([]byte(content)))
|
||||||
|
if description == "" {
|
||||||
|
description = "(no description)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, `%s\n %s\n`, info.Name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsListBuiltin(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if s.EmbeddedFS != nil {
|
||||||
|
entries, err := fs.ReadDir(s.EmbeddedFS, "workspace/skills")
|
||||||
|
if err == nil {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Fprintln(out, "No builtin skills found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
docPath := filepath.Join("workspace/skills", entry.Name(), "SKILL.md")
|
||||||
|
description := skillDescriptionFromMarkdownFS(s.EmbeddedFS, docPath)
|
||||||
|
fmt.Fprintf(out, `%s\n %s\n`, entry.Name(), description)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builtinDir := filepath.Join(s.getConfigPath(), "skills")
|
||||||
|
if err := s.printBuiltinFromPath(out, builtinDir); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyBuiltin := filepath.Join(filepath.Dir(s.getConfigPath()), "skills")
|
||||||
|
if err := s.printBuiltinFromPath(out, legacyBuiltin); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(`no builtin skills source available`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) printBuiltinFromPath(out io.Writer, dir string) error {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("builtin skills path is not a directory: %s", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Fprintln(out, "No builtin skills found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
skillPath := filepath.Join(dir, entry.Name())
|
||||||
|
docPath := filepath.Join(skillPath, "SKILL.md")
|
||||||
|
description := skillDescriptionFromMarkdown(docPath)
|
||||||
|
fmt.Fprintf(out, `%s\n %s\n`, entry.Name(), description)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsInstall(ctx context.Context, out io.Writer, repo string) error {
|
||||||
|
if strings.TrimSpace(repo) == "" {
|
||||||
|
return fmt.Errorf(`repository path required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsPath, err := config.SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := skills.NewSkillInstaller(skillsPath)
|
||||||
|
|
||||||
|
ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := installer.InstallFromGitHub(ctx2, repo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Installed skill %s\n`, repo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsInstallBuiltin(ctx context.Context, out io.Writer, workspace string) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if strings.TrimSpace(workspace) == "" {
|
||||||
|
cfg, err := s.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
workspace = cfg.WorkspacePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(workspace, "skills")
|
||||||
|
if err := os.MkdirAll(target, 0o700); err != nil {
|
||||||
|
return fmt.Errorf(`create workspace skills directory: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.EmbeddedFS != nil {
|
||||||
|
if err := s.seedEmbeddedSkills(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out, `Installed builtin skills into workspace skills directory.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
source := filepath.Join(filepath.Dir(s.getConfigPath()), "skills")
|
||||||
|
if _, err := os.Stat(source); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf(`embedded builtin skills are unavailable`)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.CopyDirectory(source, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(out, `Installed builtin skills into workspace skills directory.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsRemove(ctx context.Context, out io.Writer, name string) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return fmt.Errorf(`skill name required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsDir, err := config.SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := skills.NewSkillInstaller(skillsDir)
|
||||||
|
|
||||||
|
if err := installer.Uninstall(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Removed skill %s\n`, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsSearch(ctx context.Context, out io.Writer) error {
|
||||||
|
installer := skills.NewSkillInstaller("")
|
||||||
|
|
||||||
|
ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
skillsInfo, err := installer.ListAvailableSkills(ctx2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skillsInfo) == 0 {
|
||||||
|
fmt.Fprintln(out, "No skills available.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, skill := range skillsInfo {
|
||||||
|
name := "unknown"
|
||||||
|
if skill.Name != "" {
|
||||||
|
name = skill.Name
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
if skill.Description != "" {
|
||||||
|
description = skill.Description
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, `%s\n`, name)
|
||||||
|
if description != "" {
|
||||||
|
fmt.Fprintf(out, ` %s\n`, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SkillsShow(ctx context.Context, out io.Writer, name string) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return fmt.Errorf(`skill name required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir, err := config.SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loader := skills.NewSkillsLoader(skillDir, "", "")
|
||||||
|
|
||||||
|
content, ok := loader.LoadSkill(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf(`skill not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Name: %s\n`, name)
|
||||||
|
if content != "" {
|
||||||
|
description := strings.TrimSpace(skillDescriptionFromMarkdownData([]byte(content)))
|
||||||
|
if description != "" {
|
||||||
|
fmt.Fprintf(out, `Desc: %s\n`, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, `Content: %s\n`, content)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SecretInit(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
key, err := security.GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded := fmt.Sprintf(`%x`, key)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, `Generated master key (keep this safe!):`)
|
||||||
|
fmt.Fprintln(out, ``)
|
||||||
|
fmt.Fprintln(out, ` `+encoded)
|
||||||
|
fmt.Fprintln(out, ``)
|
||||||
|
fmt.Fprintln(out, `Set it as an environment variable:`)
|
||||||
|
fmt.Fprintln(out, ` export `+security.MasterKeyEnvVar+`=`+encoded)
|
||||||
|
fmt.Fprintln(out, ``)
|
||||||
|
fmt.Fprintln(out, `Or add to your shell profile (~/.bashrc, ~/.zshrc).`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SecretAdd(ctx context.Context, in io.Reader, out io.Writer, name string) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return fmt.Errorf(`secret name required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.secretStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(`error opening secret store: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Secret value: `)
|
||||||
|
raw, err := io.ReadAll(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strings.TrimSpace(string(raw))
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf(`secret value cannot be empty`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Set(name, []byte(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Secret %s saved.\n`, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SecretList(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
store, err := s.secretStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := store.List()
|
||||||
|
if len(names) == 0 {
|
||||||
|
fmt.Fprintln(out, "No secrets found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
fmt.Fprintln(out, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SecretDelete(ctx context.Context, out io.Writer, name string) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return fmt.Errorf(`secret name required`)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := s.secretStore()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Deleted secret %s\n`, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DaemonStart(ctx context.Context, out io.Writer) error {
|
||||||
|
cfgDir, err := config.ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfgDir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
socketPath := s.DaemonSocketPath()
|
||||||
|
pidPath := s.DaemonPIDPath()
|
||||||
|
|
||||||
|
if pidData, err := os.ReadFile(pidPath); err == nil {
|
||||||
|
pidText := strings.TrimSpace(string(pidData))
|
||||||
|
if pidText != "" {
|
||||||
|
fmt.Fprintf(out, `Daemon already running (pid %s).\n`, pidText)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := s.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
svr, err := securebus.NewSocketTransportServer(socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = svr.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
busStore, err := s.secretStore()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, `Warning: secret store unavailable: %v\n`, err)
|
||||||
|
busStore = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRegistry := tools.NewToolRegistry()
|
||||||
|
registerDefaultTools(s, toolRegistry, cfg)
|
||||||
|
|
||||||
|
baseCtx := ctx
|
||||||
|
if baseCtx == nil {
|
||||||
|
baseCtx = context.Background()
|
||||||
|
}
|
||||||
|
daemonCtx, cancel := signal.NotifyContext(baseCtx, os.Interrupt)
|
||||||
|
ctx = daemonCtx
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
capabilityLookup := func(toolName string) (tools.ToolCapabilities, bool) {
|
||||||
|
tool, ok := toolRegistry.Get(toolName)
|
||||||
|
if !ok {
|
||||||
|
return tools.ZeroCapabilities(), false
|
||||||
|
}
|
||||||
|
return tools.ExtractCapabilities(tool), true
|
||||||
|
}
|
||||||
|
executeTool := func(execCtx context.Context, name string, args map[string]interface{}) *tools.ToolResult {
|
||||||
|
return toolRegistry.Execute(execCtx, name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus := securebus.New(securebus.DefaultBusConfig(), busStore, capabilityLookup, executeTool)
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
srvErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
srvErr <- svr.Serve(func(reqCtx context.Context, req itr.ToolRequest) itr.ToolResponse {
|
||||||
|
return bus.Execute(reqCtx, req)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(pidPath)
|
||||||
|
|
||||||
|
logger.InfoF(`daemon started on socket`, map[string]interface{}{
|
||||||
|
`socket_path`: socketPath,
|
||||||
|
})
|
||||||
|
fmt.Fprintln(out, `Daemon started.`)
|
||||||
|
fmt.Fprintf(out, ` sandbox: %s\n`, cfg.SandboxPath())
|
||||||
|
fmt.Fprintf(out, ` tools: %d registered\n`, len(toolRegistry.List()))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Info(`Daemon shutdown requested`)
|
||||||
|
case err := <-srvErr:
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(`daemon stopped with error: %w`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DaemonStop(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
pidPath := s.DaemonPIDPath()
|
||||||
|
data, err := os.ReadFile(pidPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Fprintln(out, `Daemon is not running.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pidText := strings.TrimSpace(string(data))
|
||||||
|
pid, err := strconv.Atoi(pidText)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(`invalid pid file content`)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf(`failed to stop daemon: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(pidPath); err != nil {
|
||||||
|
fmt.Fprintf(out, `Could not remove pid file: %v\n`, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, `Daemon stopped (pid %d)\n`, pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DaemonStatus(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
pidPath := s.DaemonPIDPath()
|
||||||
|
data, err := os.ReadFile(pidPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
fmt.Fprintln(out, `Daemon is not running.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pidText := strings.TrimSpace(string(data))
|
||||||
|
if pidText == "" {
|
||||||
|
fmt.Fprintln(out, `Daemon is not running.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(pidText)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(`invalid pid file content`)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, `Daemon is not running (stale pid %d).\n`, pid)
|
||||||
|
return os.WriteFile(pidPath, nil, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Signal(syscall.Signal(0)); err != nil {
|
||||||
|
fmt.Fprintf(out, `Daemon is not running (stale pid %d).\n`, pid)
|
||||||
|
_ = os.Remove(pidPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `Daemon running with PID %d\n`, pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MemoryMigrateSessions(ctx context.Context, out io.Writer) error {
|
||||||
|
cfg, err := s.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := cfg.Memory.DBPath
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath, err = config.DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate, err := delegate.NewLibSQLDelegate(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(`memory delegate: %w`, err)
|
||||||
|
}
|
||||||
|
defer delegate.Close()
|
||||||
|
|
||||||
|
if err := delegate.Init(context.Background()); err != nil {
|
||||||
|
return fmt.Errorf(`initialize memory delegate: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := memory.MigrateFileSessions(ctx, delegate, pkg.NAME, filepath.Join(cfg.WorkspacePath(), ".sessions"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
fmt.Fprintln(out, `No sessions to migrate.`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, `sessions_found=%d\n`, result.SessionsFound)
|
||||||
|
fmt.Fprintf(out, `sessions_migrated=%d\n`, result.SessionsMigrated)
|
||||||
|
fmt.Fprintf(out, `items_created=%d\n`, result.ItemsCreated)
|
||||||
|
fmt.Fprintf(out, `errors=%d\n`, result.Errors)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MemoryDBStatus(ctx context.Context, out io.Writer) error {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
cfg, err := s.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := cfg.Memory.DBPath
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath, err = config.DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat, err := os.Stat(dbPath); err == nil {
|
||||||
|
fmt.Fprintf(out, `SQLite DB exists at %s\n`, dbPath)
|
||||||
|
fmt.Fprintf(out, `Size: %d bytes\n`, stat.Size())
|
||||||
|
return nil
|
||||||
|
} else if os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(out, `SQLite DB missing at %s\n`, dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) secretStore() (*security.SecretStore, error) {
|
||||||
|
secretPath := filepath.Join(filepath.Dir(s.getConfigPath()), `secrets.json`)
|
||||||
|
var keyring security.KeyringProvider
|
||||||
|
|
||||||
|
if secretKey := os.Getenv(security.MasterKeyEnvVar); secretKey != "" {
|
||||||
|
keyring = security.NewEnvKeyring(security.MasterKeyEnvVar)
|
||||||
|
} else {
|
||||||
|
keyring = security.NewNoopKeyring(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := security.NewSecretStore(secretPath, keyring)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillDescriptionFromMarkdownFS(fsys fs.FS, path string) string {
|
||||||
|
data, err := fs.ReadFile(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return `No description`
|
||||||
|
}
|
||||||
|
return skillDescriptionFromMarkdownData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillDescriptionFromMarkdown(path string) string {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return `No description`
|
||||||
|
}
|
||||||
|
return skillDescriptionFromMarkdownData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillDescriptionFromMarkdownData(data []byte) string {
|
||||||
|
for _, line := range strings.Split(string(data), `\n`) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, `description:`) {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(trimmed, `description:`))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `No description`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDefaultTools(service *Service, registry *tools.ToolRegistry, cfg *config.Config) {
|
||||||
|
_ = service
|
||||||
|
sandbox := cfg.SandboxPath()
|
||||||
|
restrictToSandbox := cfg.RestrictToSandbox()
|
||||||
|
registerTool(registry, tools.NewExecTool(sandbox, restrictToSandbox))
|
||||||
|
registerTool(registry, tools.NewReadFileTool(sandbox, restrictToSandbox))
|
||||||
|
registerTool(registry, tools.NewWriteFileTool(sandbox, restrictToSandbox))
|
||||||
|
registerTool(registry, tools.NewListDirTool(sandbox, restrictToSandbox))
|
||||||
|
registerTool(registry, tools.NewEditFileTool(sandbox, restrictToSandbox))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerTool(registry *tools.ToolRegistry, tool tools.Tool) {
|
||||||
|
registry.Register(tool)
|
||||||
|
}
|
||||||
118
pkg/eval/instrumentation/model.go
Normal file
118
pkg/eval/instrumentation/model.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package instrumentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstrumentedCall struct {
|
||||||
|
duration time.Duration
|
||||||
|
toolCalls []InstrumentedToolCall
|
||||||
|
usage fantasy.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstrumentedToolCall struct {
|
||||||
|
Name string `json:"-"`
|
||||||
|
Args map[string]interface{} `json:"-"`
|
||||||
|
Result string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstrumentedLanguageModel struct {
|
||||||
|
inner fantasy.LanguageModel
|
||||||
|
calls []InstrumentedCall
|
||||||
|
totalUsage fantasy.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fantasy.LanguageModel = (*InstrumentedLanguageModel)(nil)
|
||||||
|
|
||||||
|
func Wrap(inner fantasy.LanguageModel) *InstrumentedLanguageModel {
|
||||||
|
return &InstrumentedLanguageModel{
|
||||||
|
inner: inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) Provider() string { return m.inner.Provider() }
|
||||||
|
func (m *InstrumentedLanguageModel) Model() string { return m.inner.Model() }
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||||
|
return m.inner.GenerateObject(ctx, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||||
|
return m.inner.StreamObject(ctx, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := m.inner.Generate(ctx, call)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
instrCall := InstrumentedCall{duration: duration}
|
||||||
|
if err == nil && resp != nil {
|
||||||
|
instrCall.usage = resp.Usage
|
||||||
|
m.totalUsage.InputTokens += resp.Usage.InputTokens
|
||||||
|
m.totalUsage.OutputTokens += resp.Usage.OutputTokens
|
||||||
|
m.totalUsage.TotalTokens += resp.Usage.TotalTokens
|
||||||
|
m.totalUsage.ReasoningTokens += resp.Usage.ReasoningTokens
|
||||||
|
m.totalUsage.CacheReadTokens += resp.Usage.CacheReadTokens
|
||||||
|
|
||||||
|
for _, tc := range resp.Content.ToolCalls() {
|
||||||
|
var args map[string]interface{}
|
||||||
|
_ = json.Unmarshal([]byte(tc.Input), &args)
|
||||||
|
instrCall.toolCalls = append(instrCall.toolCalls, InstrumentedToolCall{
|
||||||
|
Name: tc.ToolName,
|
||||||
|
Args: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.calls = append(m.calls, instrCall)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||||
|
start := time.Now()
|
||||||
|
stream, err := m.inner.Stream(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
m.calls = append(m.calls, InstrumentedCall{duration: time.Since(start)})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
instrCall := InstrumentedCall{}
|
||||||
|
wrappedStream := func(yield func(fantasy.StreamPart) bool) {
|
||||||
|
stream(func(part fantasy.StreamPart) bool {
|
||||||
|
switch part.Type {
|
||||||
|
case fantasy.StreamPartTypeFinish:
|
||||||
|
instrCall.usage = part.Usage
|
||||||
|
m.totalUsage.InputTokens += part.Usage.InputTokens
|
||||||
|
m.totalUsage.OutputTokens += part.Usage.OutputTokens
|
||||||
|
m.totalUsage.TotalTokens += part.Usage.TotalTokens
|
||||||
|
m.totalUsage.ReasoningTokens += part.Usage.ReasoningTokens
|
||||||
|
m.totalUsage.CacheReadTokens += part.Usage.CacheReadTokens
|
||||||
|
case fantasy.StreamPartTypeToolCall:
|
||||||
|
var args map[string]interface{}
|
||||||
|
_ = json.Unmarshal([]byte(part.ToolCallInput), &args)
|
||||||
|
instrCall.toolCalls = append(instrCall.toolCalls, InstrumentedToolCall{
|
||||||
|
Name: part.ToolCallName,
|
||||||
|
Args: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return yield(part)
|
||||||
|
})
|
||||||
|
instrCall.duration = time.Since(start)
|
||||||
|
m.calls = append(m.calls, instrCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrappedStream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) Calls() []InstrumentedCall {
|
||||||
|
return m.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *InstrumentedLanguageModel) Usage() fantasy.Usage {
|
||||||
|
return m.totalUsage
|
||||||
|
}
|
||||||
88
pkg/eval/instrumentation/trace.go
Normal file
88
pkg/eval/instrumentation/trace.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package instrumentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Trace struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
Steps []TraceStep `json:"steps"`
|
||||||
|
Metrics Metrics `json:"metrics"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
SessionKey string `json:"session_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceStep struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
Args json.RawMessage `json:"args,omitempty"`
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
Duration int64 `json:"duration_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||||
|
StepCount int `json:"step_count"`
|
||||||
|
ToolCallCount int `json:"tool_call_count"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxEvalOutputBytes = 500
|
||||||
|
|
||||||
|
func BuildSteps(model *InstrumentedLanguageModel) []TraceStep {
|
||||||
|
var steps []TraceStep
|
||||||
|
index := 0
|
||||||
|
for _, call := range model.Calls() {
|
||||||
|
steps = append(steps, TraceStep{
|
||||||
|
Index: index,
|
||||||
|
Type: "llm_call",
|
||||||
|
Duration: call.duration.Milliseconds(),
|
||||||
|
})
|
||||||
|
index++
|
||||||
|
|
||||||
|
for _, tc := range call.toolCalls {
|
||||||
|
argsRaw, _ := json.Marshal(tc.Args)
|
||||||
|
steps = append(steps, TraceStep{
|
||||||
|
Index: index,
|
||||||
|
Type: "tool_call",
|
||||||
|
Tool: tc.Name,
|
||||||
|
Args: argsRaw,
|
||||||
|
Result: truncate(tc.Result, maxEvalOutputBytes),
|
||||||
|
})
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMetrics(model *InstrumentedLanguageModel, duration time.Duration) Metrics {
|
||||||
|
usage := model.Usage()
|
||||||
|
metric := Metrics{
|
||||||
|
TotalDurationMs: int64(duration / time.Millisecond),
|
||||||
|
StepCount: len(model.Calls()),
|
||||||
|
InputTokens: usage.InputTokens,
|
||||||
|
OutputTokens: usage.OutputTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
ReasoningTokens: usage.ReasoningTokens,
|
||||||
|
CacheReadTokens: usage.CacheReadTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, call := range model.Calls() {
|
||||||
|
metric.ToolCallCount += len(call.toolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, maxLen int) string {
|
||||||
|
if len(value) <= maxLen {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:maxLen] + "...[truncated]"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue