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