Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cmd/sgai/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ type parsedAction struct {
}

func loadActionConfigs(workspacePath string) ([]actionConfig, error) {
config, errLoad := loadProjectConfig(workspacePath)
return loadActionConfigsFromConfigPath(workspacePath, "")
}

func loadActionConfigsFromConfigPath(workspacePath, configPath string) ([]actionConfig, error) {
var (
config *projectConfig
errLoad error
)
if strings.TrimSpace(configPath) == "" {
config, errLoad = loadProjectConfig(workspacePath)
} else {
config, errLoad = loadProjectConfigPath(configPath)
}
if errLoad != nil {
return nil, errLoad
}
Expand Down
38 changes: 22 additions & 16 deletions cmd/sgai/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,30 +54,36 @@ type projectConfig struct {
}

func loadProjectConfig(dir string) (*projectConfig, error) {
configPath := filepath.Join(dir, configFileName)
return loadProjectConfigFile(filepath.Join(dir, configFileName), true)
}

data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
if os.IsPermission(err) {
return nil, fmt.Errorf("permission denied reading config file: %s", configPath)
func loadProjectConfigPath(configPath string) (*projectConfig, error) {
return loadProjectConfigFile(configPath, false)
}

func loadProjectConfigFile(configPath string, allowMissing bool) (*projectConfig, error) {
data, errRead := os.ReadFile(configPath)
if errRead != nil {
if os.IsNotExist(errRead) {
if allowMissing {
return nil, nil
}
return nil, fmt.Errorf("reading config file %s: %w", configPath, errRead)
}
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
return nil, fmt.Errorf("reading config file %s: %w", configPath, errRead)
}

var config projectConfig
if err := json.Unmarshal(data, &config); err != nil {
if errSyntax, ok := err.(*json.SyntaxError); ok {
if errUnmarshal := json.Unmarshal(data, &config); errUnmarshal != nil {
if errSyntax, ok := errUnmarshal.(*json.SyntaxError); ok {
return nil, fmt.Errorf("invalid JSON syntax in config file %s at offset %d: %w",
configPath, errSyntax.Offset, err)
configPath, errSyntax.Offset, errUnmarshal)
}
if errUnmarshal, ok := err.(*json.UnmarshalTypeError); ok {
return nil, fmt.Errorf("invalid JSON type in config file %s at field %s: expected %s, got %s",
configPath, errUnmarshal.Field, errUnmarshal.Type, errUnmarshal.Value)
if errType, ok := errUnmarshal.(*json.UnmarshalTypeError); ok {
return nil, fmt.Errorf("invalid JSON type in config file %s at field %s: expected %s, got %s: %w",
configPath, errType.Field, errType.Type, errType.Value, errUnmarshal)
}
return nil, fmt.Errorf("parsing config file %s: %w", configPath, err)
return nil, fmt.Errorf("parsing config file %s: %w", configPath, errUnmarshal)
}

return &config, nil
Expand Down
29 changes: 29 additions & 0 deletions cmd/sgai/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,35 @@ func TestLoadProjectConfig(t *testing.T) {
}
}

func TestLoadProjectConfigPathWrapsPermissionError(t *testing.T) {
configPath := filepath.Join(t.TempDir(), configFileName)
require.NoError(t, os.WriteFile(configPath, []byte(`{"defaultModel":"test"}`), 0o600))
require.NoError(t, os.Chmod(configPath, 0))
t.Cleanup(func() {
_ = os.Chmod(configPath, 0o600)
})

_, errLoad := loadProjectConfigPath(configPath)

require.Error(t, errLoad)
assert.Contains(t, errLoad.Error(), configPath)
assert.ErrorIs(t, errLoad, os.ErrPermission)
}

func TestLoadProjectConfigPathWrapsJSONTypeError(t *testing.T) {
configPath := filepath.Join(t.TempDir(), configFileName)
require.NoError(t, os.WriteFile(configPath, []byte(`{"actions":"bad"}`), 0o644))

_, errLoad := loadProjectConfigPath(configPath)

require.Error(t, errLoad)
assert.Contains(t, errLoad.Error(), "invalid JSON type")
var errType *json.UnmarshalTypeError
require.ErrorAs(t, errLoad, &errType)
require.NotNil(t, errType)
assert.Equal(t, "actions", errType.Field)
}

func TestValidateProjectConfig(t *testing.T) {
tests := []struct {
name string
Expand Down
48 changes: 39 additions & 9 deletions cmd/sgai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func main() {
case "help", "-h", "--help":
printUsage()
return
case "run":
os.Exit(cmdRun(os.Args[2:]))
return
case "serve":
cmdServe(os.Args[2:])
return
Expand All @@ -83,7 +86,7 @@ func configureSgaiLogger(w io.Writer) {

func requiresOpencode(subcommand string) bool {
switch subcommand {
case "help", "-h", "--help", "internal-mcp":
case "help", "-h", "--help", "internal-mcp", "run":
return false
default:
return true
Expand All @@ -94,16 +97,19 @@ func printUsage() {
fmt.Println(`sgai - AI-powered software factory

Usage:
sgai [--listen-addr addr] Start web server (default)
sgai [--listen-addr addr] Start web server (default)
sgai run [--config path] [--var key=value] <action-name>

Options:
--listen-addr HTTP server listen address (default: 127.0.0.1:8080)
--listen-addr HTTP server listen address (default: 127.0.0.1:8080)

Examples:
sgai
Start web UI on localhost:8080
sgai --listen-addr 0.0.0.0:8080
Start web UI accessible externally`)
sgai
Start web UI on localhost:8080
sgai run --config ./verification/sgai.json --var Name=Ada Summarize
Run a configured action from the CLI
sgai --listen-addr 0.0.0.0:8080
Start web UI accessible externally`)
}

// runWorkflow executes the main workflow loop for a target directory.
Expand Down Expand Up @@ -1488,12 +1494,36 @@ func terminateProcessGroupOnCancel(ctx context.Context, cmd *exec.Cmd, processEx
case <-processExited:
return
}
terminateProcessGroup(cmd, processExited, waitForGracefulProcessExit, syscall.Kill)
}

func terminateProcessGroup(cmd *exec.Cmd, processExited <-chan struct{}, waitForExit func(<-chan struct{}) bool, signalProcessGroup func(int, syscall.Signal) error) {
if cmd.Process == nil || processGroupExited(processExited) {
return
}
pgid := -cmd.Process.Pid
_ = syscall.Kill(pgid, syscall.SIGTERM)
_ = signalProcessGroup(pgid, syscall.SIGTERM)
if waitForExit(processExited) || processGroupExited(processExited) {
return
}
_ = signalProcessGroup(pgid, syscall.SIGKILL)
}

func waitForGracefulProcessExit(processExited <-chan struct{}) bool {
select {
case <-processExited:
return true
case <-time.After(gracefulShutdownTimeout):
_ = syscall.Kill(pgid, syscall.SIGKILL)
return processGroupExited(processExited)
}
}

func processGroupExited(processExited <-chan struct{}) bool {
select {
case <-processExited:
return true
default:
return false
}
}

Expand Down
60 changes: 50 additions & 10 deletions cmd/sgai/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -5214,20 +5215,59 @@ func TestHandleCompleteStatusCoordinatorNoBlockers(t *testing.T) {
assert.Equal(t, state.StatusComplete, result.Status)
}

func TestTerminateProcessGroupOnCancelWithContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.Command("sleep", "30")
require.NoError(t, cmd.Start())
func TestTerminateProcessGroupUsesProcessGroupID(t *testing.T) {
exited := make(chan struct{})
var pids []int
var signals []syscall.Signal

cmd := exec.Command("true")
cmd.Process = &os.Process{Pid: 42}
terminateProcessGroup(cmd, exited, func(<-chan struct{}) bool {
close(exited)
return false
}, func(pid int, sig syscall.Signal) error {
pids = append(pids, pid)
signals = append(signals, sig)
return nil
})

assert.Equal(t, []int{-42}, pids)
assert.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
}

func TestTerminateProcessGroupSkipsSignalsWhenProcessAlreadyExited(t *testing.T) {
exited := make(chan struct{})
go func() {
_ = cmd.Wait()
close(exited)

var signals []syscall.Signal
cmd := exec.Command("true")
cmd.Process = &os.Process{Pid: 42}
terminateProcessGroup(cmd, exited, func(<-chan struct{}) bool {
t.Fatal("unexpected grace-period wait")
return false
}, func(_ int, sig syscall.Signal) error {
signals = append(signals, sig)
return nil
})

assert.Empty(t, signals)
}

func TestTerminateProcessGroupSkipsEscalationAfterProcessExit(t *testing.T) {
exited := make(chan struct{})

var signals []syscall.Signal
cmd := exec.Command("true")
cmd.Process = &os.Process{Pid: 42}
terminateProcessGroup(cmd, exited, func(<-chan struct{}) bool {
close(exited)
}()
return false
}, func(_ int, sig syscall.Signal) error {
signals = append(signals, sig)
return nil
})

cancel()
terminateProcessGroupOnCancel(ctx, cmd, exited)
<-exited
assert.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
}

func TestExportSessionMissingBinary(t *testing.T) {
Expand Down
Loading