mirror of
https://github.com/langgenius/dify.git
synced 2026-08-31 01:36:38 +08:00
fix(dify-agent): add reliable shellctl stdio mode (#39997)
This commit is contained in:
@@ -14,6 +14,21 @@ cmd/
|
||||
internal/ - internal implementations
|
||||
```
|
||||
|
||||
## Job execution modes
|
||||
|
||||
`POST /v1/jobs/run` accepts an optional `mode` field:
|
||||
|
||||
- `pty` (default) keeps the interactive tmux PTY path. stdout and stderr are
|
||||
merged, sanitized, and written to `output.log`; the job accepts `/input`.
|
||||
- `stdio` keeps tmux as the lifecycle owner but gives the child `/dev/null` as
|
||||
stdin and captures stdout and stderr through separate pipes. Public output
|
||||
and pagination read stdout from `output.log`; private diagnostics are written
|
||||
to `stderr.log`. A stdio job completes only after both streams reach EOF and
|
||||
does not accept `/input`.
|
||||
|
||||
The response models are identical in both modes. Use `stdio` for bounded,
|
||||
machine-readable control commands and `pty` for interactive jobs.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// shellctl-runner is the Go replacement for the previously generated bash+python
|
||||
// runner script. It is invoked by tmux as:
|
||||
//
|
||||
// shellctl-runner <job_dir> <job_id> <cwd>
|
||||
// shellctl-runner <job_dir> <job_id> <cwd> [pty|stdio]
|
||||
//
|
||||
// The binary operates in two modes:
|
||||
// The binary operates in two process roles:
|
||||
//
|
||||
// 1. Parent mode (default): waits for start-gate, loads env, forks child,
|
||||
// waits for exit, writes exit artifacts.
|
||||
@@ -17,16 +17,19 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/cmdutil"
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/envvar"
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/landlock"
|
||||
)
|
||||
|
||||
@@ -39,21 +42,25 @@ func main() {
|
||||
}
|
||||
|
||||
// parentMode is the entry point when called by tmux.
|
||||
// Args: shellctl-runner <job_dir> <job_id> <cwd>
|
||||
// Args: shellctl-runner <job_dir> <job_id> <cwd> [pty|stdio]
|
||||
func parentMode() {
|
||||
if len(os.Args) < 4 {
|
||||
cmdutil.HandleError(fmt.Errorf("bad args"), 125, "usage: shellctl-runner <job_dir> <job_id> <cwd>")
|
||||
cmdutil.HandleError(fmt.Errorf("bad args"), 125, "usage: shellctl-runner <job_dir> <job_id> <cwd> [pty|stdio]")
|
||||
}
|
||||
|
||||
jobDir := os.Args[1]
|
||||
// jobID := os.Args[2] // unused in parent but passed for compat
|
||||
cwd := os.Args[3]
|
||||
modeRaw := ""
|
||||
if len(os.Args) >= 5 {
|
||||
modeRaw = os.Args[4]
|
||||
}
|
||||
mode, err := jobmode.Parse(modeRaw)
|
||||
cmdutil.HandleError(err, 125, "parse job mode")
|
||||
|
||||
scriptPath := filepath.Join(jobDir, "script")
|
||||
envPath := filepath.Join(jobDir, ".job-env.json")
|
||||
startGate := filepath.Join(jobDir, "start-gate")
|
||||
exitCodePath := filepath.Join(jobDir, "runner-exit-code")
|
||||
endedAtPath := filepath.Join(jobDir, "runner-ended-at")
|
||||
|
||||
// Wait for start-gate.
|
||||
for {
|
||||
@@ -104,9 +111,6 @@ func parentMode() {
|
||||
|
||||
cmd := exec.Command(self, childArgs...)
|
||||
cmd.Env = env
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Dir = cwd
|
||||
|
||||
// Forward signals to child.
|
||||
@@ -120,23 +124,154 @@ func parentMode() {
|
||||
}
|
||||
}()
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := runCommandAndRecordExit(cmd, jobDir, mode)
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = 125
|
||||
}
|
||||
// runCommandAndRecordExit publishes the existing runner artifacts after the
|
||||
// child path returns. In stdio mode that return includes both stream drains;
|
||||
// PTY mode still relies on its separate pipe-drain finalizer.
|
||||
func runCommandAndRecordExit(cmd *exec.Cmd, jobDir string, mode jobmode.Mode) int {
|
||||
var exitCode int
|
||||
if mode == jobmode.Stdio {
|
||||
exitCode = runStdio(cmd, jobDir)
|
||||
} else {
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
exitCode = runPTY(cmd)
|
||||
}
|
||||
|
||||
endedAt := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
writeAtomic(exitCodePath, fmt.Sprintf("%d", exitCode))
|
||||
writeAtomic(endedAtPath, endedAt)
|
||||
writeAtomic(filepath.Join(jobDir, "runner-exit-code"), fmt.Sprintf("%d", exitCode))
|
||||
writeAtomic(filepath.Join(jobDir, "runner-ended-at"), endedAt)
|
||||
return exitCode
|
||||
}
|
||||
|
||||
os.Exit(exitCode)
|
||||
func runPTY(cmd *exec.Cmd) int {
|
||||
return commandExitCode(cmd.Run())
|
||||
}
|
||||
|
||||
// runStdio captures stdout and stderr independently and does not return until
|
||||
// both streams reach EOF and their files are closed.
|
||||
func runStdio(cmd *exec.Cmd, jobDir string) int {
|
||||
outputFile, err := openCaptureFile(filepath.Join(jobDir, "output.log"))
|
||||
if err != nil {
|
||||
return runnerError("open stdout capture", err)
|
||||
}
|
||||
stderrFile, err := openCaptureFile(filepath.Join(jobDir, "stderr.log"))
|
||||
if err != nil {
|
||||
_ = outputFile.Close()
|
||||
return runnerError("open stderr capture", err)
|
||||
}
|
||||
|
||||
stdoutReader, stdoutWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = outputFile.Close()
|
||||
_ = stderrFile.Close()
|
||||
return runnerError("create stdout pipe", err)
|
||||
}
|
||||
stderrReader, stderrWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = stdoutReader.Close()
|
||||
_ = stdoutWriter.Close()
|
||||
_ = outputFile.Close()
|
||||
_ = stderrFile.Close()
|
||||
return runnerError("create stderr pipe", err)
|
||||
}
|
||||
stdinFile, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
_ = stdoutReader.Close()
|
||||
_ = stdoutWriter.Close()
|
||||
_ = stderrReader.Close()
|
||||
_ = stderrWriter.Close()
|
||||
_ = outputFile.Close()
|
||||
_ = stderrFile.Close()
|
||||
return runnerError("open stdin", err)
|
||||
}
|
||||
|
||||
cmd.Stdin = stdinFile
|
||||
cmd.Stdout = stdoutWriter
|
||||
cmd.Stderr = stderrWriter
|
||||
|
||||
var captureErrors []error
|
||||
var captureErrorsMu sync.Mutex
|
||||
recordError := func(operation string, err error) {
|
||||
if err != nil {
|
||||
captureErrorsMu.Lock()
|
||||
captureErrors = append(captureErrors, fmt.Errorf("%s: %w", operation, err))
|
||||
captureErrorsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
var drains sync.WaitGroup
|
||||
drains.Add(2)
|
||||
go func() {
|
||||
defer drains.Done()
|
||||
_, copyErr := io.Copy(outputFile, stdoutReader)
|
||||
recordError("copy stdout", copyErr)
|
||||
recordError("close stdout reader", stdoutReader.Close())
|
||||
}()
|
||||
go func() {
|
||||
defer drains.Done()
|
||||
_, copyErr := io.Copy(stderrFile, stderrReader)
|
||||
recordError("copy stderr", copyErr)
|
||||
recordError("close stderr reader", stderrReader.Close())
|
||||
}()
|
||||
|
||||
startErr := cmd.Start()
|
||||
recordError("close stdout writer", stdoutWriter.Close())
|
||||
recordError("close stderr writer", stderrWriter.Close())
|
||||
recordError("close stdin", stdinFile.Close())
|
||||
|
||||
var waitErr error
|
||||
if startErr != nil {
|
||||
recordError("start child", startErr)
|
||||
} else {
|
||||
waitErr = cmd.Wait()
|
||||
}
|
||||
|
||||
// Descendants may retain either write end after the direct child exits. In
|
||||
// that case the runner intentionally remains alive until both reach EOF.
|
||||
drains.Wait()
|
||||
recordError("close stdout capture", outputFile.Close())
|
||||
recordError("close stderr capture", stderrFile.Close())
|
||||
|
||||
for _, captureErr := range captureErrors {
|
||||
fmt.Fprintf(os.Stderr, "shellctl-runner: %v\n", captureErr)
|
||||
}
|
||||
if len(captureErrors) > 0 {
|
||||
return 125
|
||||
}
|
||||
return commandExitCode(waitErr)
|
||||
}
|
||||
|
||||
func openCaptureFile(path string) (*os.File, error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := file.Chmod(0600); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func runnerError(operation string, err error) int {
|
||||
fmt.Fprintf(os.Stderr, "shellctl-runner: %s: %v\n", operation, err)
|
||||
return 125
|
||||
}
|
||||
|
||||
func commandExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return 125
|
||||
}
|
||||
|
||||
// childMode applies Landlock (if --landlock flag) and exec's the user script.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
func TestRunStdioCapturesCompleteSeparatedStreams(t *testing.T) {
|
||||
jobDir := t.TempDir()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestStdioHelperProcess$")
|
||||
cmd.Env = append(os.Environ(), "SHELLCTL_STDIO_HELPER=large-output")
|
||||
|
||||
if exitCode := runStdio(cmd, jobDir); exitCode != 0 {
|
||||
t.Fatalf("runStdio exit code = %d, want 0", exitCode)
|
||||
}
|
||||
|
||||
wantStdout := bytes.Repeat([]byte("stdout-payload\n"), 16*1024)
|
||||
wantStderr := bytes.Repeat([]byte("stderr-payload\n"), 16*1024)
|
||||
gotStdout, err := os.ReadFile(filepath.Join(jobDir, "output.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read output.log: %v", err)
|
||||
}
|
||||
gotStderr, err := os.ReadFile(filepath.Join(jobDir, "stderr.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("read stderr.log: %v", err)
|
||||
}
|
||||
if !bytes.Equal(gotStdout, wantStdout) {
|
||||
t.Errorf("stdout capture length = %d, want %d", len(gotStdout), len(wantStdout))
|
||||
}
|
||||
if !bytes.Equal(gotStderr, wantStderr) {
|
||||
t.Errorf("stderr capture length = %d, want %d", len(gotStderr), len(wantStderr))
|
||||
}
|
||||
for _, name := range []string{"output.log", "stderr.log"} {
|
||||
info, err := os.Stat(filepath.Join(jobDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", name, err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0600 {
|
||||
t.Errorf("%s permissions = %#o, want 0600", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdioUsesNonTTYStreams(t *testing.T) {
|
||||
jobDir := t.TempDir()
|
||||
cmd := exec.Command("sh", "-c", `if [ -t 0 ] || [ -t 1 ] || [ -t 2 ]; then exit 1; fi; printf 'stdout-only'; printf 'warning' >&2`)
|
||||
|
||||
if exitCode := runStdio(cmd, jobDir); exitCode != 0 {
|
||||
t.Fatalf("runStdio exit code = %d, want 0", exitCode)
|
||||
}
|
||||
stdout, err := os.ReadFile(filepath.Join(jobDir, "output.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := os.ReadFile(filepath.Join(jobDir, "stderr.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(stdout) != "stdout-only" {
|
||||
t.Errorf("stdout = %q, want stdout-only", stdout)
|
||||
}
|
||||
if string(stderr) != "warning" {
|
||||
t.Errorf("stderr = %q, want warning", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdioWaitsForBothDescendantStreamsBeforePublishingExit(t *testing.T) {
|
||||
jobDir := t.TempDir()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestStdioHelperProcess$")
|
||||
cmd.Env = mergeEnv(os.Environ(), map[string]string{
|
||||
"SHELLCTL_STDIO_HELPER": "spawn-descendants",
|
||||
"SHELLCTL_STDIO_JOB_DIR": jobDir,
|
||||
})
|
||||
done := make(chan struct{})
|
||||
var exitCode int
|
||||
go func() {
|
||||
exitCode = runCommandAndRecordExit(cmd, jobDir, jobmode.Stdio)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
stdoutRelease := filepath.Join(jobDir, "release-stdout")
|
||||
stderrRelease := filepath.Join(jobDir, "release-stderr")
|
||||
t.Cleanup(func() {
|
||||
_ = os.WriteFile(stdoutRelease, nil, 0600)
|
||||
_ = os.WriteFile(stderrRelease, nil, 0600)
|
||||
for _, name := range []string{"stdout-closed", "stderr-closed"} {
|
||||
if !waitForPath(filepath.Join(jobDir, name), 5*time.Second) {
|
||||
t.Errorf("cleanup timed out waiting for %s", name)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("cleanup timed out waiting for runner completion")
|
||||
}
|
||||
})
|
||||
|
||||
waitForTestFile(t, filepath.Join(jobDir, "direct-child-exited"))
|
||||
assertRunnerRemainsIncomplete(t, done)
|
||||
assertExitArtifactsAbsent(t, jobDir)
|
||||
|
||||
if err := os.WriteFile(stdoutRelease, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTestFile(t, filepath.Join(jobDir, "stdout-closed"))
|
||||
assertRunnerRemainsIncomplete(t, done)
|
||||
assertExitArtifactsAbsent(t, jobDir)
|
||||
|
||||
if err := os.WriteFile(stderrRelease, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTestFile(t, filepath.Join(jobDir, "stderr-closed"))
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0", exitCode)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runner did not complete after both descendant streams reached EOF")
|
||||
}
|
||||
waitForTestFile(t, filepath.Join(jobDir, "runner-exit-code"))
|
||||
waitForTestFile(t, filepath.Join(jobDir, "runner-ended-at"))
|
||||
|
||||
stdout, err := os.ReadFile(filepath.Join(jobDir, "output.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := os.ReadFile(filepath.Join(jobDir, "stderr.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(stdout) != "stdout-tail" {
|
||||
t.Errorf("stdout = %q, want stdout-tail", stdout)
|
||||
}
|
||||
if string(stderr) != "stderr-tail" {
|
||||
t.Errorf("stderr = %q, want stderr-tail", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdioPreservesNonZeroExitCode(t *testing.T) {
|
||||
jobDir := t.TempDir()
|
||||
cmd := exec.Command("sh", "-c", "exit 23")
|
||||
|
||||
if exitCode := runCommandAndRecordExit(cmd, jobDir, jobmode.Stdio); exitCode != 23 {
|
||||
t.Fatalf("exit code = %d, want 23", exitCode)
|
||||
}
|
||||
exitCodeArtifact, err := os.ReadFile(filepath.Join(jobDir, "runner-exit-code"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(exitCodeArtifact) != "23\n" {
|
||||
t.Errorf("runner-exit-code = %q, want 23", exitCodeArtifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioHelperProcess(t *testing.T) {
|
||||
switch os.Getenv("SHELLCTL_STDIO_HELPER") {
|
||||
case "":
|
||||
return
|
||||
case "large-output":
|
||||
stdout := bytes.Repeat([]byte("stdout-payload\n"), 16*1024)
|
||||
stderr := bytes.Repeat([]byte("stderr-payload\n"), 16*1024)
|
||||
_, _ = os.Stdout.Write(stdout)
|
||||
_, _ = os.Stderr.Write(stderr)
|
||||
os.Exit(0)
|
||||
case "spawn-descendants":
|
||||
spawnStdioDescendants()
|
||||
case "hold-stdout", "hold-stderr":
|
||||
holdStdioStream(os.Getenv("SHELLCTL_STDIO_HELPER"))
|
||||
default:
|
||||
os.Exit(125)
|
||||
}
|
||||
}
|
||||
|
||||
func spawnStdioDescendants() {
|
||||
jobDir := os.Getenv("SHELLCTL_STDIO_JOB_DIR")
|
||||
parentPID := strconv.Itoa(os.Getpid())
|
||||
stdoutHolder := exec.Command(os.Args[0], "-test.run=^TestStdioHelperProcess$")
|
||||
stdoutHolder.Env = mergeEnv(os.Environ(), map[string]string{
|
||||
"SHELLCTL_STDIO_HELPER": "hold-stdout",
|
||||
"SHELLCTL_STDIO_JOB_DIR": jobDir,
|
||||
"SHELLCTL_STDIO_PARENT_PID": parentPID,
|
||||
})
|
||||
stdoutHolder.Stdout = os.Stdout
|
||||
if err := stdoutHolder.Start(); err != nil {
|
||||
os.Exit(125)
|
||||
}
|
||||
|
||||
stderrHolder := exec.Command(os.Args[0], "-test.run=^TestStdioHelperProcess$")
|
||||
stderrHolder.Env = mergeEnv(os.Environ(), map[string]string{
|
||||
"SHELLCTL_STDIO_HELPER": "hold-stderr",
|
||||
"SHELLCTL_STDIO_JOB_DIR": jobDir,
|
||||
})
|
||||
stderrHolder.Stderr = os.Stderr
|
||||
if err := stderrHolder.Start(); err != nil {
|
||||
os.Exit(125)
|
||||
}
|
||||
|
||||
if !waitForPath(filepath.Join(jobDir, "stdout-ready"), 5*time.Second) ||
|
||||
!waitForPath(filepath.Join(jobDir, "stderr-ready"), 5*time.Second) {
|
||||
os.Exit(125)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func holdStdioStream(mode string) {
|
||||
jobDir := os.Getenv("SHELLCTL_STDIO_JOB_DIR")
|
||||
streamName := mode[len("hold-"):]
|
||||
if err := os.WriteFile(filepath.Join(jobDir, streamName+"-ready"), nil, 0600); err != nil {
|
||||
os.Exit(125)
|
||||
}
|
||||
if mode == "hold-stdout" {
|
||||
parentPID, err := strconv.Atoi(os.Getenv("SHELLCTL_STDIO_PARENT_PID"))
|
||||
if err != nil || !waitForProcessExit(parentPID, 5*time.Second) {
|
||||
os.Exit(125)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "direct-child-exited"), nil, 0600); err != nil {
|
||||
os.Exit(125)
|
||||
}
|
||||
}
|
||||
|
||||
if !waitForPath(filepath.Join(jobDir, "release-"+streamName), 5*time.Second) {
|
||||
os.Exit(125)
|
||||
}
|
||||
if mode == "hold-stdout" {
|
||||
_, _ = os.Stdout.WriteString("stdout-tail")
|
||||
_ = os.Stdout.Close()
|
||||
} else {
|
||||
_, _ = os.Stderr.WriteString("stderr-tail")
|
||||
_ = os.Stderr.Close()
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, streamName+"-closed"), nil, 0600); err != nil {
|
||||
os.Exit(125)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := syscall.Kill(pid, 0); err == syscall.ESRCH {
|
||||
return true
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitForPath(path string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return true
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitForTestFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if !waitForPath(path, 5*time.Second) {
|
||||
t.Fatalf("timed out waiting for %s", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
func assertRunnerRemainsIncomplete(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(100 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("runner completed before both streams reached EOF")
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
func assertExitArtifactsAbsent(t *testing.T, jobDir string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"runner-exit-code", "runner-ended-at"} {
|
||||
if _, err := os.Stat(filepath.Join(jobDir, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s became visible before both streams reached EOF: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package jobmode defines the execution modes shared by the shellctl server
|
||||
// and runner.
|
||||
package jobmode
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Mode selects how the runner connects a job's standard streams.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
PTY Mode = "pty"
|
||||
Stdio Mode = "stdio"
|
||||
)
|
||||
|
||||
// Parse validates a mode received at a process or API boundary. An empty value
|
||||
// preserves the historical PTY behavior for callers that omit the mode.
|
||||
func Parse(raw string) (Mode, error) {
|
||||
if raw == "" {
|
||||
return PTY, nil
|
||||
}
|
||||
|
||||
mode := Mode(raw)
|
||||
switch mode {
|
||||
case PTY, Stdio:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid job mode %q", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package jobmode
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want Mode
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "omitted", raw: "", want: PTY},
|
||||
{name: "pty", raw: "pty", want: PTY},
|
||||
{name: "stdio", raw: "stdio", want: Stdio},
|
||||
{name: "unknown", raw: "stdout", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.raw)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Parse(%q) error = %v, wantErr %v", tt.raw, err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
// Handler creates the HTTP handler (mux) for the shellctl API.
|
||||
@@ -45,6 +47,12 @@ func handleRunJob(svc *Service) http.HandlerFunc {
|
||||
writeError(w, 400, "invalid_request", "script is required")
|
||||
return
|
||||
}
|
||||
mode, err := jobmode.Parse(string(req.Mode))
|
||||
if err != nil {
|
||||
writeError(w, 422, "validation_error", err.Error())
|
||||
return
|
||||
}
|
||||
req.Mode = mode
|
||||
// Validate env
|
||||
if req.Env != nil {
|
||||
for name, value := range req.Env {
|
||||
|
||||
@@ -152,6 +152,25 @@ func TestHealthzHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRejectsInvalidModeBeforeCallingService(t *testing.T) {
|
||||
handler := handleRunJob(nil)
|
||||
req := httptest.NewRequest("POST", "/v1/jobs/run", strings.NewReader(`{"script":"true","mode":"stdout"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
var result ErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Error.Code != "validation_error" {
|
||||
t.Errorf("expected validation_error, got %q", result.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerErrorFormat(t *testing.T) {
|
||||
err := NewServerError(422, "validation_error", "bad input")
|
||||
expected := "[422] validation_error: bad input"
|
||||
|
||||
@@ -5,9 +5,19 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const latestSchemaVersion = 1
|
||||
|
||||
type schemaMigration func(*sql.Tx) error
|
||||
|
||||
var schemaMigrations = []schemaMigration{
|
||||
migrateSchemaV1,
|
||||
}
|
||||
|
||||
// JobStatusName represents the lifecycle states of a shellctl job.
|
||||
type JobStatusName string
|
||||
|
||||
@@ -35,6 +45,7 @@ type JobRow struct {
|
||||
JobID string
|
||||
ScriptPath string
|
||||
OutputPath string
|
||||
Mode jobmode.Mode
|
||||
Cwd string
|
||||
TerminalCols int
|
||||
TerminalRows int
|
||||
@@ -72,9 +83,63 @@ func (d *DB) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// InitSchema creates the jobs table if it does not exist.
|
||||
// InitSchema creates the v0 baseline and applies pending schema migrations.
|
||||
func (d *DB) InitSchema() error {
|
||||
_, err := d.db.Exec(`
|
||||
var currentVersion int
|
||||
if err := d.db.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
if currentVersion > latestSchemaVersion {
|
||||
return fmt.Errorf(
|
||||
"database schema version %d is newer than supported version %d",
|
||||
currentVersion,
|
||||
latestSchemaVersion,
|
||||
)
|
||||
}
|
||||
|
||||
if currentVersion == 0 {
|
||||
if err := d.createSchemaV0(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for currentVersion < latestSchemaVersion {
|
||||
targetVersion := currentVersion + 1
|
||||
if err := d.applySchemaMigration(targetVersion, schemaMigrations[targetVersion-1]); err != nil {
|
||||
return err
|
||||
}
|
||||
currentVersion = targetVersion
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySchemaMigration commits migration SQL and PRAGMA user_version together,
|
||||
// rolling back the entire version if either operation fails.
|
||||
func (d *DB) applySchemaMigration(targetVersion int, migration schemaMigration) error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin schema migration v%d: %w", targetVersion, err)
|
||||
}
|
||||
if err := migration(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply schema migration v%d: %w", targetVersion, err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", targetVersion)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record schema migration v%d: %w", targetVersion, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit schema migration v%d: %w", targetVersion, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) createSchemaV0() error {
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin schema v0: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
script_path TEXT NOT NULL,
|
||||
@@ -93,18 +158,29 @@ func (d *DB) InitSchema() error {
|
||||
ended_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("create schema v0: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit schema v0: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateSchemaV1(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE jobs ADD COLUMN mode TEXT NOT NULL DEFAULT 'pty'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertJob inserts a new job row. Returns false if the job_id already exists.
|
||||
func (d *DB) InsertJob(row *JobRow) (bool, error) {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO jobs (job_id, script_path, output_path, cwd, terminal_cols, terminal_rows,
|
||||
INSERT INTO jobs (job_id, script_path, output_path, mode, cwd, terminal_cols, terminal_rows,
|
||||
status, session_name, pane_target, exit_code, reason, message,
|
||||
created_at, started_at, ended_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
row.JobID, row.ScriptPath, row.OutputPath, row.Cwd,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
row.JobID, row.ScriptPath, row.OutputPath, string(row.Mode), row.Cwd,
|
||||
row.TerminalCols, row.TerminalRows,
|
||||
string(row.Status), row.SessionName, row.PaneTarget,
|
||||
row.ExitCode, row.Reason, row.Message,
|
||||
@@ -123,7 +199,7 @@ func (d *DB) InsertJob(row *JobRow) (bool, error) {
|
||||
// GetJob retrieves a single job row by ID.
|
||||
func (d *DB) GetJob(jobID string) (*JobRow, error) {
|
||||
row := d.db.QueryRow(`
|
||||
SELECT job_id, script_path, output_path, cwd, terminal_cols, terminal_rows,
|
||||
SELECT job_id, script_path, output_path, mode, cwd, terminal_cols, terminal_rows,
|
||||
status, session_name, pane_target, exit_code, reason, message,
|
||||
created_at, started_at, ended_at, updated_at
|
||||
FROM jobs WHERE job_id = ?`, jobID)
|
||||
@@ -136,7 +212,7 @@ func (d *DB) ListJobs(statuses []JobStatusName) ([]*JobRow, error) {
|
||||
var err error
|
||||
|
||||
if len(statuses) == 0 {
|
||||
rows, err = d.db.Query(`SELECT job_id, script_path, output_path, cwd,
|
||||
rows, err = d.db.Query(`SELECT job_id, script_path, output_path, mode, cwd,
|
||||
terminal_cols, terminal_rows, status, session_name, pane_target,
|
||||
exit_code, reason, message, created_at, started_at, ended_at, updated_at
|
||||
FROM jobs ORDER BY created_at DESC`)
|
||||
@@ -150,7 +226,7 @@ func (d *DB) ListJobs(statuses []JobStatusName) ([]*JobRow, error) {
|
||||
}
|
||||
placeholders += "?"
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT job_id, script_path, output_path, cwd,
|
||||
query := fmt.Sprintf(`SELECT job_id, script_path, output_path, mode, cwd,
|
||||
terminal_cols, terminal_rows, status, session_name, pane_target,
|
||||
exit_code, reason, message, created_at, started_at, ended_at, updated_at
|
||||
FROM jobs WHERE status IN (%s) ORDER BY created_at DESC`, placeholders)
|
||||
@@ -313,9 +389,9 @@ type TransitionOpts struct {
|
||||
|
||||
func scanJobRow(row *sql.Row) (*JobRow, error) {
|
||||
var jr JobRow
|
||||
var status string
|
||||
var mode, status string
|
||||
err := row.Scan(
|
||||
&jr.JobID, &jr.ScriptPath, &jr.OutputPath, &jr.Cwd,
|
||||
&jr.JobID, &jr.ScriptPath, &jr.OutputPath, &mode, &jr.Cwd,
|
||||
&jr.TerminalCols, &jr.TerminalRows,
|
||||
&status, &jr.SessionName, &jr.PaneTarget,
|
||||
&jr.ExitCode, &jr.Reason, &jr.Message,
|
||||
@@ -327,15 +403,16 @@ func scanJobRow(row *sql.Row) (*JobRow, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jr.Mode = jobmode.Mode(mode)
|
||||
jr.Status = JobStatusName(status)
|
||||
return &jr, nil
|
||||
}
|
||||
|
||||
func scanJobRows(rows *sql.Rows) (*JobRow, error) {
|
||||
var jr JobRow
|
||||
var status string
|
||||
var mode, status string
|
||||
err := rows.Scan(
|
||||
&jr.JobID, &jr.ScriptPath, &jr.OutputPath, &jr.Cwd,
|
||||
&jr.JobID, &jr.ScriptPath, &jr.OutputPath, &mode, &jr.Cwd,
|
||||
&jr.TerminalCols, &jr.TerminalRows,
|
||||
&status, &jr.SessionName, &jr.PaneTarget,
|
||||
&jr.ExitCode, &jr.Reason, &jr.Message,
|
||||
@@ -344,6 +421,7 @@ func scanJobRows(rows *sql.Rows) (*JobRow, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jr.Mode = jobmode.Mode(mode)
|
||||
jr.Status = JobStatusName(status)
|
||||
return &jr, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
func TestJobStatusIsTerminal(t *testing.T) {
|
||||
@@ -40,6 +44,7 @@ func TestOpenDBAndInitSchema(t *testing.T) {
|
||||
JobID: "test-job-1",
|
||||
ScriptPath: "jobs/test-job-1/script",
|
||||
OutputPath: "jobs/test-job-1/output.log",
|
||||
Mode: jobmode.PTY,
|
||||
Cwd: "/tmp",
|
||||
TerminalCols: 80,
|
||||
TerminalRows: 24,
|
||||
@@ -66,6 +71,104 @@ func TestOpenDBAndInitSchema(t *testing.T) {
|
||||
if ok {
|
||||
t.Error("expected duplicate insert to return ok=false")
|
||||
}
|
||||
if got := schemaVersion(t, db); got != latestSchemaVersion {
|
||||
t.Errorf("schema version = %d, want %d", got, latestSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitSchemaMigratesV0JobsToPTY(t *testing.T) {
|
||||
db := openTestDB(t, t.TempDir())
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if err := db.createSchemaV0(); err != nil {
|
||||
t.Fatalf("createSchemaV0: %v", err)
|
||||
}
|
||||
_, err := db.db.Exec(`
|
||||
INSERT INTO jobs (
|
||||
job_id, script_path, output_path, cwd, terminal_cols, terminal_rows,
|
||||
status, session_name, pane_target, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"legacy-job", "jobs/legacy-job/script", "jobs/legacy-job/output.log", "/tmp",
|
||||
80, 24, "created", "shellctl-legacy-job", "shellctl-legacy-job:0.0",
|
||||
"2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert legacy job: %v", err)
|
||||
}
|
||||
|
||||
if err := db.InitSchema(); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
row, err := db.GetJob("legacy-job")
|
||||
if err != nil {
|
||||
t.Fatalf("GetJob: %v", err)
|
||||
}
|
||||
if row.Mode != jobmode.PTY {
|
||||
t.Errorf("legacy job mode = %q, want %q", row.Mode, jobmode.PTY)
|
||||
}
|
||||
if got := schemaVersion(t, db); got != latestSchemaVersion {
|
||||
t.Errorf("schema version = %d, want %d", got, latestSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitSchemaAtLatestVersionIsIdempotent(t *testing.T) {
|
||||
db := setupTestDB(t, t.TempDir())
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if err := db.InitSchema(); err != nil {
|
||||
t.Fatalf("second InitSchema: %v", err)
|
||||
}
|
||||
if got := schemaVersion(t, db); got != latestSchemaVersion {
|
||||
t.Errorf("schema version = %d, want %d", got, latestSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitSchemaRejectsNewerDatabaseWithoutDDL(t *testing.T) {
|
||||
db := openTestDB(t, t.TempDir())
|
||||
defer func() { _ = db.Close() }()
|
||||
if _, err := db.db.Exec("PRAGMA user_version = 2"); err != nil {
|
||||
t.Fatalf("set future schema version: %v", err)
|
||||
}
|
||||
|
||||
if err := db.InitSchema(); err == nil {
|
||||
t.Fatal("InitSchema unexpectedly accepted a newer schema")
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='jobs'`).Scan(&tableCount); err != nil {
|
||||
t.Fatalf("query jobs table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Errorf("jobs table count = %d, want 0", tableCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySchemaMigrationFailureRollsBackDDLAndVersion(t *testing.T) {
|
||||
db := openTestDB(t, t.TempDir())
|
||||
defer func() { _ = db.Close() }()
|
||||
if err := db.createSchemaV0(); err != nil {
|
||||
t.Fatalf("createSchemaV0: %v", err)
|
||||
}
|
||||
|
||||
sentinel := errors.New("sentinel migration failure")
|
||||
err := db.applySchemaMigration(1, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`ALTER TABLE jobs ADD COLUMN rollback_probe TEXT`); err != nil {
|
||||
return err
|
||||
}
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("migration error = %v, want sentinel failure", err)
|
||||
}
|
||||
var probeColumns int
|
||||
if err := db.db.QueryRow(`SELECT count(*) FROM pragma_table_info('jobs') WHERE name = 'rollback_probe'`).Scan(&probeColumns); err != nil {
|
||||
t.Fatalf("query rollback probe column: %v", err)
|
||||
}
|
||||
if probeColumns != 0 {
|
||||
t.Errorf("rollback_probe column count = %d, want 0", probeColumns)
|
||||
}
|
||||
if got := schemaVersion(t, db); got != 0 {
|
||||
t.Errorf("schema version = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJob(t *testing.T) {
|
||||
@@ -88,6 +191,9 @@ func TestGetJob(t *testing.T) {
|
||||
if row.TerminalCols != 80 {
|
||||
t.Errorf("expected cols=80, got %d", row.TerminalCols)
|
||||
}
|
||||
if row.Mode != jobmode.PTY {
|
||||
t.Errorf("expected mode=pty, got %s", row.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJobNotFound(t *testing.T) {
|
||||
@@ -243,6 +349,7 @@ func TestRecordRunnerExitIdempotent(t *testing.T) {
|
||||
exitCode := 10
|
||||
row := &JobRow{
|
||||
JobID: "job-exit-2", ScriptPath: "x", OutputPath: "y", Cwd: "/tmp",
|
||||
Mode: jobmode.PTY,
|
||||
TerminalCols: 80, TerminalRows: 24, Status: StatusExited,
|
||||
SessionName: "s", PaneTarget: "p", ExitCode: &exitCode,
|
||||
CreatedAt: "2025-01-01T00:00:00Z", UpdatedAt: "2025-01-01T00:00:00Z",
|
||||
@@ -267,24 +374,40 @@ func TestRecordRunnerExitIdempotent(t *testing.T) {
|
||||
// Helpers
|
||||
|
||||
func setupTestDB(t *testing.T, dir string) *DB {
|
||||
t.Helper()
|
||||
db := openTestDB(t, dir)
|
||||
if err := db.InitSchema(); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T, dir string) *DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(dir, "shellctl.db")
|
||||
db, err := OpenDB(dbPath, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
if err := db.InitSchema(); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func schemaVersion(t *testing.T, db *DB) int {
|
||||
t.Helper()
|
||||
var version int
|
||||
if err := db.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatalf("read schema version: %v", err)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func insertTestJob(t *testing.T, db *DB, jobID string, status JobStatusName) {
|
||||
t.Helper()
|
||||
row := &JobRow{
|
||||
JobID: jobID,
|
||||
ScriptPath: "jobs/" + jobID + "/script",
|
||||
OutputPath: "jobs/" + jobID + "/output.log",
|
||||
Mode: jobmode.PTY,
|
||||
Cwd: "/tmp",
|
||||
TerminalCols: 80,
|
||||
TerminalRows: 24,
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
// Service is the core job lifecycle manager backed by SQLite and tmux.
|
||||
@@ -105,7 +107,9 @@ func (s *Service) StartBackgroundGC() {
|
||||
}()
|
||||
}
|
||||
|
||||
// StartBackgroundPipeMonitor starts the periodic pipe health check goroutine.
|
||||
// StartBackgroundPipeMonitor periodically reconciles mode-aware runtime state:
|
||||
// PTY pane pipe/drain state, and stdio session state plus completion
|
||||
// materialization from exit artifacts.
|
||||
func (s *Service) StartBackgroundPipeMonitor() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancelMon = cancel
|
||||
@@ -194,6 +198,7 @@ func (s *Service) RunJob(req *RunJobRequest) (*JobResult, error) {
|
||||
JobID: jobID,
|
||||
ScriptPath: fmt.Sprintf("jobs/%s/script", jobID),
|
||||
OutputPath: fmt.Sprintf("jobs/%s/output.log", jobID),
|
||||
Mode: req.Mode,
|
||||
Cwd: cwd,
|
||||
TerminalCols: cols,
|
||||
TerminalRows: rows,
|
||||
@@ -220,9 +225,10 @@ func (s *Service) RunJob(req *RunJobRequest) (*JobResult, error) {
|
||||
Target: StatusStarting,
|
||||
})
|
||||
|
||||
// Create tmux session and enable output pipe
|
||||
// Start the mode-specific runtime: PTY manages pane piping and drain, while
|
||||
// stdio tracks the session and materializes completion from exit artifacts.
|
||||
log.Printf("RunJob [%s]: starting job, cwd=%s", jobID, cwd)
|
||||
startErr := s.startJob(jobID, jobDir, cwd, cols, rows)
|
||||
startErr := s.startJob(jobID, jobDir, cwd, cols, rows, req.Mode)
|
||||
if startErr != nil {
|
||||
log.Printf("RunJob [%s]: start failed: %v", jobID, startErr)
|
||||
reason := "start_failed"
|
||||
@@ -251,24 +257,26 @@ func (s *Service) RunJob(req *RunJobRequest) (*JobResult, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) startJob(jobID, jobDir, cwd string, cols, rows int) error {
|
||||
func (s *Service) startJob(jobID, jobDir, cwd string, cols, rows int, mode jobmode.Mode) error {
|
||||
log.Printf("startJob [%s]: creating tmux session", jobID)
|
||||
if err := s.tmux.CreateJobSession(jobID, jobDir, cwd, cols, rows); err != nil {
|
||||
if err := s.tmux.CreateJobSession(jobID, jobDir, cwd, cols, rows, mode); err != nil {
|
||||
log.Printf("startJob [%s]: tmux session failed: %v", jobID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
pipeReadyPath := filepath.Join(jobDir, ".pipe-ready")
|
||||
log.Printf("startJob [%s]: enabling output pipe", jobID)
|
||||
if err := s.tmux.EnableOutputPipe(jobID, jobDir, pipeReadyPath); err != nil {
|
||||
log.Printf("startJob [%s]: pipe-pane failed: %v", jobID, err)
|
||||
return err
|
||||
}
|
||||
if mode == jobmode.PTY {
|
||||
log.Printf("startJob [%s]: enabling output pipe", jobID)
|
||||
if err := s.tmux.EnableOutputPipe(jobID, jobDir, pipeReadyPath); err != nil {
|
||||
log.Printf("startJob [%s]: pipe-pane failed: %v", jobID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for pipe ready handshake
|
||||
if err := s.waitForPipeReady(jobID, pipeReadyPath); err != nil {
|
||||
log.Printf("startJob [%s]: pipe-ready timeout: %v", jobID, err)
|
||||
return err
|
||||
// Wait for pipe ready handshake
|
||||
if err := s.waitForPipeReady(jobID, pipeReadyPath); err != nil {
|
||||
log.Printf("startJob [%s]: pipe-ready timeout: %v", jobID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Open start gate
|
||||
@@ -285,8 +293,9 @@ func (s *Service) startJob(jobID, jobDir, cwd string, cols, rows int) error {
|
||||
RequireExitCodeNull: true,
|
||||
})
|
||||
|
||||
// Clean up ready file
|
||||
_ = os.Remove(pipeReadyPath)
|
||||
if mode == jobmode.PTY {
|
||||
_ = os.Remove(pipeReadyPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -425,11 +434,15 @@ func (s *Service) TailJob(jobID string, outputLimit int) (*JobResult, error) {
|
||||
|
||||
// GetJobStatus materializes the current status from SQLite + live tmux state.
|
||||
func (s *Service) GetJobStatus(jobID string) (*JobStatusView, error) {
|
||||
sessionExists, pipeActive, err := s.liveRuntimeState(jobID)
|
||||
row, err := s.db.GetJob(jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.materializeStatusView(jobID, sessionExists, pipeActive)
|
||||
sessionExists, pipeActive, err := s.liveRuntimeState(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.materializeStatusView(row, sessionExists, pipeActive)
|
||||
}
|
||||
|
||||
// ListJobs returns recent jobs, optionally filtered by status.
|
||||
@@ -474,6 +487,13 @@ func (s *Service) SendInput(jobID string, req *InputJobRequest) (*JobResult, err
|
||||
if view.Done {
|
||||
return nil, NewServerError(409, "job_not_running", fmt.Sprintf("Job %s is already terminal", jobID))
|
||||
}
|
||||
row, err := s.db.GetJob(jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.Mode == jobmode.Stdio {
|
||||
return nil, NewServerError(409, "input_unsupported", "stdio jobs do not support input")
|
||||
}
|
||||
|
||||
if err := s.tmux.SendInput(jobID, req.Text); err != nil {
|
||||
// Check if job became terminal in the meantime
|
||||
@@ -594,7 +614,8 @@ func (s *Service) GCOnce() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckRunningJobsPipeHealth fails running jobs whose pipe died.
|
||||
// CheckRunningJobsPipeHealth reconciles PTY pane pipe/drain state and stdio
|
||||
// session state, materializing completion from exit artifacts.
|
||||
func (s *Service) CheckRunningJobsPipeHealth() {
|
||||
rows, _ := s.db.ListJobs([]JobStatusName{StatusRunning})
|
||||
for _, row := range rows {
|
||||
@@ -602,12 +623,8 @@ func (s *Service) CheckRunningJobsPipeHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) materializeStatusView(jobID string, sessionExists bool, pipeActive *bool) (*JobStatusView, error) {
|
||||
row, err := s.db.GetJob(jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *Service) materializeStatusView(row *JobRow, sessionExists bool, pipeActive *bool) (*JobStatusView, error) {
|
||||
jobID := row.JobID
|
||||
status := row.Status
|
||||
|
||||
if status.IsTerminal() {
|
||||
@@ -631,14 +648,14 @@ func (s *Service) materializeStatusView(jobID string, sessionExists bool, pipeAc
|
||||
}); err == nil {
|
||||
row = r
|
||||
}
|
||||
} else if exit := s.drainedNormalExitMetadata(jobID); exit != nil {
|
||||
// Recover from drained exit artifacts
|
||||
} else if exit := s.completedExitMetadata(row); exit != nil {
|
||||
// Recover from mode-specific completed exit artifacts.
|
||||
_ = s.db.RecordRunnerExit(jobID, exit.exitCode, exit.endedAt)
|
||||
if r, err := s.db.GetJob(jobID); err == nil {
|
||||
row = r
|
||||
}
|
||||
} else if sessionExists {
|
||||
if pipeActive != nil && !*pipeActive {
|
||||
if row.Mode == jobmode.PTY && pipeActive != nil && !*pipeActive {
|
||||
s.mu.Lock()
|
||||
isStarting := s.startingJobs[jobID]
|
||||
s.mu.Unlock()
|
||||
@@ -670,7 +687,7 @@ func (s *Service) materializeStatusView(jobID string, sessionExists bool, pipeAc
|
||||
}
|
||||
} else {
|
||||
// No session
|
||||
if s.normalExitCommitPending(jobID) {
|
||||
if s.normalExitCommitPending(row) {
|
||||
// Wait for pipe drain finalizer
|
||||
} else {
|
||||
s.mu.Lock()
|
||||
@@ -694,15 +711,18 @@ func (s *Service) materializeStatusView(jobID string, sessionExists bool, pipeAc
|
||||
return s.statusViewFromRow(row), nil
|
||||
}
|
||||
|
||||
func (s *Service) liveRuntimeState(jobID string) (bool, *bool, error) {
|
||||
exists, err := s.tmux.SessionExists(JobSessionName(jobID))
|
||||
func (s *Service) liveRuntimeState(row *JobRow) (bool, *bool, error) {
|
||||
exists, err := s.tmux.SessionExists(row.SessionName)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil, nil
|
||||
}
|
||||
active, err := s.tmux.IsOutputPipeActive(jobID)
|
||||
if row.Mode == jobmode.Stdio {
|
||||
return true, nil, nil
|
||||
}
|
||||
active, err := s.tmux.IsOutputPipeActive(row.JobID)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
@@ -717,13 +737,16 @@ type exitMetadata struct {
|
||||
endedAt string
|
||||
}
|
||||
|
||||
func (s *Service) drainedNormalExitMetadata(jobID string) *exitMetadata {
|
||||
jobDir := filepath.Join(s.config.JobsDir(), jobID)
|
||||
func (s *Service) completedExitMetadata(row *JobRow) *exitMetadata {
|
||||
jobDir := filepath.Join(s.config.JobsDir(), row.JobID)
|
||||
drainedPath := filepath.Join(jobDir, ".pipe-drained")
|
||||
exitCodePath := filepath.Join(jobDir, "runner-exit-code")
|
||||
endedAtPath := filepath.Join(jobDir, "runner-ended-at")
|
||||
|
||||
if !fileExists(drainedPath) || !fileExists(exitCodePath) || !fileExists(endedAtPath) {
|
||||
if row.Mode == jobmode.PTY && !fileExists(drainedPath) {
|
||||
return nil
|
||||
}
|
||||
if !fileExists(exitCodePath) || !fileExists(endedAtPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -750,8 +773,11 @@ func (s *Service) drainedNormalExitMetadata(jobID string) *exitMetadata {
|
||||
return &exitMetadata{exitCode: code, endedAt: endedAtStr}
|
||||
}
|
||||
|
||||
func (s *Service) normalExitCommitPending(jobID string) bool {
|
||||
jobDir := filepath.Join(s.config.JobsDir(), jobID)
|
||||
func (s *Service) normalExitCommitPending(row *JobRow) bool {
|
||||
if row.Mode != jobmode.PTY {
|
||||
return false
|
||||
}
|
||||
jobDir := filepath.Join(s.config.JobsDir(), row.JobID)
|
||||
return fileExists(filepath.Join(jobDir, "runner-exit-code")) &&
|
||||
fileExists(filepath.Join(jobDir, "runner-ended-at")) &&
|
||||
!fileExists(filepath.Join(jobDir, ".pipe-drained")) &&
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
func TestMaterializeStdioStatusDoesNotRequirePanePipe(t *testing.T) {
|
||||
service, row := setupModeTestService(t, jobmode.Stdio, StatusRunning)
|
||||
pipeInactive := false
|
||||
|
||||
view, err := service.materializeStatusView(row, true, &pipeInactive)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeStatusView: %v", err)
|
||||
}
|
||||
if view.Status != StatusRunning {
|
||||
t.Errorf("status = %q, want %q", view.Status, StatusRunning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeStdioStatusUsesExitArtifactsWithoutPipeDrainMarker(t *testing.T) {
|
||||
service, row := setupModeTestService(t, jobmode.Stdio, StatusRunning)
|
||||
jobDir := filepath.Join(service.config.JobsDir(), row.JobID)
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "runner-exit-code"), []byte("7\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const endedAt = "2026-08-04T10:00:00Z"
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "runner-ended-at"), []byte(endedAt+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := service.materializeStatusView(row, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeStatusView: %v", err)
|
||||
}
|
||||
if view.Status != StatusExited || !view.Done {
|
||||
t.Errorf("view = status %q done %v, want exited and done", view.Status, view.Done)
|
||||
}
|
||||
if view.ExitCode == nil || *view.ExitCode != 7 {
|
||||
t.Errorf("exit code = %v, want 7", view.ExitCode)
|
||||
}
|
||||
if view.EndedAt == nil || *view.EndedAt != endedAt {
|
||||
t.Errorf("ended_at = %v, want %s", view.EndedAt, endedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializeStdioStatusMarksMissingSessionWithIncompleteArtifactsLost(t *testing.T) {
|
||||
service, row := setupModeTestService(t, jobmode.Stdio, StatusRunning)
|
||||
jobDir := filepath.Join(service.config.JobsDir(), row.JobID)
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "runner-exit-code"), []byte("0\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := service.materializeStatusView(row, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeStatusView: %v", err)
|
||||
}
|
||||
if view.Status != StatusLost {
|
||||
t.Errorf("status = %q, want %q", view.Status, StatusLost)
|
||||
}
|
||||
}
|
||||
|
||||
func setupModeTestService(t *testing.T, mode jobmode.Mode, status JobStatusName) (*Service, *JobRow) {
|
||||
t.Helper()
|
||||
stateDir := t.TempDir()
|
||||
config := DefaultConfig()
|
||||
config.StateDir = stateDir
|
||||
config.RuntimeDir = filepath.Join(stateDir, "runtime")
|
||||
if err := os.MkdirAll(config.JobsDir(), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := setupTestDB(t, stateDir)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
service := NewService(config)
|
||||
service.db = db
|
||||
|
||||
const jobID = "mode-test-job"
|
||||
jobDir := filepath.Join(config.JobsDir(), jobID)
|
||||
if err := os.MkdirAll(jobDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "output.log"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := &JobRow{
|
||||
JobID: jobID,
|
||||
ScriptPath: "jobs/mode-test-job/script",
|
||||
OutputPath: "jobs/mode-test-job/output.log",
|
||||
Mode: mode,
|
||||
Cwd: "/tmp",
|
||||
TerminalCols: 80,
|
||||
TerminalRows: 24,
|
||||
Status: status,
|
||||
SessionName: JobSessionName(jobID),
|
||||
PaneTarget: JobPaneTarget(jobID),
|
||||
CreatedAt: "2026-08-04T09:00:00Z",
|
||||
UpdatedAt: "2026-08-04T09:00:00Z",
|
||||
}
|
||||
inserted, err := db.InsertJob(row)
|
||||
if err != nil || !inserted {
|
||||
t.Fatalf("InsertJob: inserted=%v err=%v", inserted, err)
|
||||
}
|
||||
persisted, err := db.GetJob(jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJob: %v", err)
|
||||
}
|
||||
return service, persisted
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
)
|
||||
|
||||
// TmuxController manages tmux sessions for shellctl jobs via a dedicated socket.
|
||||
@@ -82,9 +84,13 @@ func (t *TmuxController) IsOutputPipeActive(jobID string) (*bool, error) {
|
||||
}
|
||||
|
||||
// CreateJobSession creates a new tmux session for a job.
|
||||
func (t *TmuxController) CreateJobSession(jobID, jobDir, cwd string, cols, rows int) error {
|
||||
func (t *TmuxController) CreateJobSession(
|
||||
jobID, jobDir, cwd string,
|
||||
cols, rows int,
|
||||
mode jobmode.Mode,
|
||||
) error {
|
||||
runnerCmd := shellJoin([]string{
|
||||
t.config.RunnerPath(), jobDir, jobID, cwd,
|
||||
t.config.RunnerPath(), jobDir, jobID, cwd, string(mode),
|
||||
})
|
||||
result, err := t.runTmuxNoCheck(
|
||||
"-f", "/dev/null",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package server
|
||||
|
||||
import "github.com/langgenius/dify/dify-agent-runtime/internal/jobmode"
|
||||
|
||||
// RunJobRequest is the HTTP request body for POST /v1/jobs/run.
|
||||
type RunJobRequest struct {
|
||||
Script string `json:"script"`
|
||||
Cwd *string `json:"cwd,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Terminal *TerminalSize `json:"terminal,omitempty"`
|
||||
Mode jobmode.Mode `json:"mode,omitempty"`
|
||||
Timeout float64 `json:"timeout,omitempty"`
|
||||
OutputLimit int `json:"output_limit,omitempty"`
|
||||
IdleFlushSeconds float64 `json:"idle_flush_seconds,omitempty"`
|
||||
|
||||
@@ -170,6 +170,84 @@ func TestRunSimpleScript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPTYModesMergeStdoutAndStderr(t *testing.T) {
|
||||
for _, tgt := range targets() {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode string
|
||||
}{
|
||||
{name: "default"},
|
||||
{name: "explicit", mode: "pty"},
|
||||
} {
|
||||
t.Run(tgt.name+"/"+tc.name, func(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"script": "printf 'stdout-marker\\n'; printf 'stderr-marker\\n' >&2",
|
||||
"timeout": 10,
|
||||
}
|
||||
if tc.mode != "" {
|
||||
payload["mode"] = tc.mode
|
||||
}
|
||||
result := runJob(t, tgt, payload)
|
||||
assertJobDone(t, result)
|
||||
assertExitCode(t, result, 0)
|
||||
output := result["output"].(string)
|
||||
if !strings.Contains(output, "stdout-marker") || !strings.Contains(output, "stderr-marker") {
|
||||
t.Fatalf("PTY output did not merge stdout and stderr: %q", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdioSeparatesStdoutAndStderr(t *testing.T) {
|
||||
for _, tgt := range targets() {
|
||||
t.Run(tgt.name, func(t *testing.T) {
|
||||
result := runJob(t, tgt, map[string]any{
|
||||
"script": "printf '{\"ok\":true}'\nprintf 'warning' >&2",
|
||||
"mode": "stdio",
|
||||
"timeout": 10,
|
||||
})
|
||||
assertJobDone(t, result)
|
||||
assertExitCode(t, result, 0)
|
||||
if output := result["output"].(string); output != `{"ok":true}` {
|
||||
t.Errorf("stdio output = %q, want stdout-only JSON", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioInputIsRejected(t *testing.T) {
|
||||
for _, tgt := range targets() {
|
||||
t.Run(tgt.name, func(t *testing.T) {
|
||||
result := runJob(t, tgt, map[string]any{
|
||||
"script": "sleep 60",
|
||||
"mode": "stdio",
|
||||
"timeout": 0.1,
|
||||
})
|
||||
jobID := result["job_id"].(string)
|
||||
defer func() {
|
||||
resp := doPost(t, tgt, fmt.Sprintf("/v1/jobs/%s/terminate", jobID), map[string]any{"grace_seconds": 0}, true)
|
||||
resp.Body.Close()
|
||||
}()
|
||||
|
||||
resp := doPost(t, tgt, fmt.Sprintf("/v1/jobs/%s/input", jobID), map[string]any{
|
||||
"text": "ignored\n",
|
||||
"offset": 0,
|
||||
"timeout": 1,
|
||||
}, true)
|
||||
assertStatus(t, resp, http.StatusConflict)
|
||||
body := readBody(t, resp)
|
||||
var failure map[string]map[string]string
|
||||
if err := json.Unmarshal(body, &failure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := failure["error"]["code"]; code != "input_unsupported" {
|
||||
t.Errorf("error code = %q, want input_unsupported", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithEnv(t *testing.T) {
|
||||
for _, tgt := range targets() {
|
||||
t.Run(tgt.name, func(t *testing.T) {
|
||||
@@ -431,10 +509,12 @@ func TestSendInput(t *testing.T) {
|
||||
"timeout": 2, // Will timeout waiting for input
|
||||
})
|
||||
jobID := result["job_id"].(string)
|
||||
t.Cleanup(func() {
|
||||
cleanupJobBestEffort(tgt, jobID)
|
||||
})
|
||||
|
||||
if result["done"] == true {
|
||||
// Already finished (possible race), skip
|
||||
t.Skip("job completed before input could be sent")
|
||||
t.Fatalf("interactive PTY job completed before input was sent: %#v", result)
|
||||
}
|
||||
|
||||
// Send input
|
||||
@@ -451,7 +531,7 @@ func TestSendInput(t *testing.T) {
|
||||
json.Unmarshal(body, &inputResult)
|
||||
output := inputResult["output"].(string)
|
||||
if !strings.Contains(output, "got:hello-input") {
|
||||
t.Logf("output after input: %q (may need more wait time)", output)
|
||||
t.Fatalf("input result did not contain command echo: %q", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -720,6 +800,25 @@ func doPost(t *testing.T, tgt target, path string, payload map[string]any, withA
|
||||
return resp
|
||||
}
|
||||
|
||||
func cleanupJobBestEffort(tgt target, jobID string) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
body, _ := json.Marshal(map[string]any{"grace_seconds": 0})
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/v1/jobs/%s/terminate", tgt.baseURL, jobID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
req, _ = http.NewRequest("DELETE", fmt.Sprintf("%s/v1/jobs/%s?force=true&grace_seconds=0", tgt.baseURL, jobID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -9,6 +9,7 @@ from dify_agent.adapters.shell.protocols import (
|
||||
ShellCommandProtocol,
|
||||
ShellCommandResult,
|
||||
ShellCommandStatus,
|
||||
ShellExecutionMode,
|
||||
ShellPromptObservation,
|
||||
ShellProviderError,
|
||||
)
|
||||
@@ -27,6 +28,7 @@ __all__ = [
|
||||
"ShellCommandProtocol",
|
||||
"ShellCommandResult",
|
||||
"ShellCommandStatus",
|
||||
"ShellExecutionMode",
|
||||
"ShellPromptObservation",
|
||||
"ShellProviderError",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,9 @@ from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
|
||||
type ShellExecutionMode = Literal["pty", "stdio"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ShellCommandResult:
|
||||
job_id: str
|
||||
@@ -63,6 +66,7 @@ class ShellCommandProtocol(Protocol):
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult: ...
|
||||
|
||||
async def wait(
|
||||
|
||||
@@ -14,12 +14,13 @@ from typing import Protocol, TypeVar, cast
|
||||
|
||||
import httpx2 as httpx
|
||||
from shellctl.client import ShellctlClientError
|
||||
from shellctl.shared import HealthResponse
|
||||
from shellctl.shared import HealthResponse, JobMode
|
||||
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellCommandProtocol,
|
||||
ShellCommandResult,
|
||||
ShellCommandStatus,
|
||||
ShellExecutionMode,
|
||||
ShellProviderError,
|
||||
)
|
||||
|
||||
@@ -60,6 +61,7 @@ class ShellctlClientProtocol(Protocol):
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
mode: JobMode = JobMode.PTY,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def wait(
|
||||
@@ -114,6 +116,7 @@ class ShellctlCommands(ShellCommandProtocol):
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult:
|
||||
resolved_cwd = _resolve_lease_cwd(
|
||||
cwd,
|
||||
@@ -122,7 +125,15 @@ class ShellctlCommands(ShellCommandProtocol):
|
||||
)
|
||||
resolved_env = _lease_env(env, home_dir=self.home_dir)
|
||||
return _from_job_result(
|
||||
await _run_client_call(self.client.run(script, cwd=resolved_cwd, env=resolved_env, timeout=timeout))
|
||||
await _run_client_call(
|
||||
self.client.run(
|
||||
script,
|
||||
cwd=resolved_cwd,
|
||||
env=resolved_env,
|
||||
timeout=timeout,
|
||||
mode=JobMode(mode),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def wait(
|
||||
|
||||
@@ -460,6 +460,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
),
|
||||
timeout=timeout,
|
||||
max_output_bytes=max_output_bytes,
|
||||
mode="stdio",
|
||||
)
|
||||
|
||||
async def run_remote_script(
|
||||
@@ -488,6 +489,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
env=self._build_shell_command_env(include_agent_stub_env=False),
|
||||
timeout=DEFAULT_TIMEOUT_SECONDS,
|
||||
max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES,
|
||||
mode="stdio",
|
||||
)
|
||||
|
||||
def _require_resource(self) -> RuntimeLease:
|
||||
|
||||
@@ -10,6 +10,7 @@ from dify_agent.adapters.shell.protocols import (
|
||||
CompleteShellCommandResult,
|
||||
ShellCommandProtocol,
|
||||
ShellCommandResult,
|
||||
ShellExecutionMode,
|
||||
)
|
||||
from dify_agent.layers.shell.output_text import utf8_prefix
|
||||
|
||||
@@ -26,6 +27,7 @@ async def execute_complete_with_commands(
|
||||
env: dict[str, str] | None,
|
||||
timeout: float,
|
||||
max_output_bytes: int,
|
||||
mode: ShellExecutionMode,
|
||||
) -> CompleteShellCommandResult:
|
||||
"""Run a command to completion with bounded output and deterministic cleanup."""
|
||||
|
||||
@@ -36,7 +38,13 @@ async def execute_complete_with_commands(
|
||||
captured_bytes = 0
|
||||
incomplete_reason: Literal["output_limit", "timeout"] | None = None
|
||||
try:
|
||||
result = await commands.run(script, cwd=cwd, env=env, timeout=_remaining_time(deadline))
|
||||
result = await commands.run(
|
||||
script,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
timeout=_remaining_time(deadline),
|
||||
mode=mode,
|
||||
)
|
||||
job_id = result.job_id
|
||||
while True:
|
||||
remaining_bytes = max(max_output_bytes - captured_bytes, 0)
|
||||
|
||||
@@ -112,8 +112,8 @@ async def run_shellctl_control_command(
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
) -> CompleteShellCommandResult:
|
||||
"""Run one bounded driver control command and always delete its transient job."""
|
||||
result = await commands.run(script, cwd=None, env=None, timeout=timeout)
|
||||
"""Run one bounded control command through stdout-only stdio and delete its transient job."""
|
||||
result = await commands.run(script, cwd=None, env=None, timeout=timeout, mode="stdio")
|
||||
job_id = result.job_id
|
||||
output_parts = [result.output]
|
||||
try:
|
||||
|
||||
@@ -166,6 +166,7 @@ class BindingFileService:
|
||||
env={"HOME": lease.layout.home_dir},
|
||||
timeout=_BROWSE_TIMEOUT_SECONDS,
|
||||
max_output_bytes=_BROWSE_OUTPUT_MAX_BYTES,
|
||||
mode="stdio",
|
||||
)
|
||||
payload = _require_browse_payload(result, operation="list")
|
||||
try:
|
||||
@@ -193,6 +194,7 @@ class BindingFileService:
|
||||
env={"HOME": lease.layout.home_dir},
|
||||
timeout=_BROWSE_TIMEOUT_SECONDS,
|
||||
max_output_bytes=_BROWSE_OUTPUT_MAX_BYTES,
|
||||
mode="stdio",
|
||||
)
|
||||
payload = _require_browse_payload(result, operation="read")
|
||||
try:
|
||||
@@ -243,6 +245,7 @@ class BindingFileService:
|
||||
env=env,
|
||||
timeout=_DOWNLOAD_TIMEOUT_SECONDS,
|
||||
max_output_bytes=_DOWNLOAD_OUTPUT_MAX_BYTES,
|
||||
mode="stdio",
|
||||
)
|
||||
except ShellProviderError as exc:
|
||||
if exc.code == "timeout":
|
||||
|
||||
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobMode,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
@@ -63,6 +64,7 @@ __all__ = [
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobMode",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
@@ -97,6 +99,7 @@ _EXPORTS = {
|
||||
"HealthResponse": "shellctl.shared",
|
||||
"InputJobRequest": "shellctl.shared",
|
||||
"JobInfo": "shellctl.shared",
|
||||
"JobMode": "shellctl.shared",
|
||||
"JobResult": "shellctl.shared",
|
||||
"JobStatusName": "shellctl.shared",
|
||||
"JobStatusView": "shellctl.shared",
|
||||
|
||||
@@ -29,6 +29,7 @@ from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobMode,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
@@ -144,11 +145,14 @@ class ShellctlClient:
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
terminal: TerminalSize | None = None,
|
||||
mode: JobMode = JobMode.PTY,
|
||||
) -> JobResult:
|
||||
"""Create a new job and wait for initial output or completion.
|
||||
|
||||
`cwd` and `env` preset the script's working directory and environment
|
||||
overlay on the server side.
|
||||
overlay on the server side. `mode="stdio"` provides stdout-only public
|
||||
output for non-interactive commands; the default PTY mode remains
|
||||
interactive and merges stdout with stderr.
|
||||
"""
|
||||
|
||||
payload = RunJobRequest(
|
||||
@@ -156,6 +160,7 @@ class ShellctlClient:
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
terminal=terminal,
|
||||
mode=mode,
|
||||
timeout=timeout,
|
||||
output_limit=self.output_limit,
|
||||
idle_flush_seconds=self.idle_flush_seconds,
|
||||
|
||||
@@ -60,6 +60,7 @@ if TYPE_CHECKING:
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobMode,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
@@ -101,6 +102,7 @@ __all__ = [
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobMode",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
@@ -166,6 +168,7 @@ _EXPORTS = {
|
||||
"HealthResponse": "shellctl.shared.schemas",
|
||||
"InputJobRequest": "shellctl.shared.schemas",
|
||||
"JobInfo": "shellctl.shared.schemas",
|
||||
"JobMode": "shellctl.shared.schemas",
|
||||
"JobResult": "shellctl.shared.schemas",
|
||||
"JobStatusName": "shellctl.shared.schemas",
|
||||
"JobStatusView": "shellctl.shared.schemas",
|
||||
|
||||
@@ -42,6 +42,13 @@ class JobStatusName(StrEnum):
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
class JobMode(StrEnum):
|
||||
"""Standard-stream wiring used to execute a shellctl job."""
|
||||
|
||||
PTY = "pty"
|
||||
STDIO = "stdio"
|
||||
|
||||
|
||||
TERMINAL_JOB_STATUSES = frozenset(
|
||||
{
|
||||
JobStatusName.EXITED,
|
||||
@@ -139,6 +146,7 @@ class RunJobRequest(ShellctlModel):
|
||||
cwd: str | None = None
|
||||
env: dict[str, str] | None = None
|
||||
terminal: TerminalSize | None = None
|
||||
mode: JobMode = JobMode.PTY
|
||||
timeout: float = Field(default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=SHELL_TOOL_HARD_TIMEOUT_SECONDS)
|
||||
output_limit: int = Field(default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES)
|
||||
idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
|
||||
@@ -201,6 +209,7 @@ __all__ = [
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobMode",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import cast
|
||||
import httpx2 as httpx
|
||||
import pytest
|
||||
from shellctl.client import ShellctlClientError
|
||||
from shellctl.shared import JobMode
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellProviderError
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlCommands
|
||||
@@ -39,12 +40,20 @@ class _Status:
|
||||
class _Client:
|
||||
run_result: object = field(default_factory=_Job)
|
||||
delete_error: Exception | None = None
|
||||
run_calls: list[tuple[str, str | None, dict[str, str] | None, float]] = field(default_factory=list)
|
||||
run_calls: list[tuple[str, str | None, dict[str, str] | None, float, JobMode]] = field(default_factory=list)
|
||||
wait_calls: list[tuple[str, int, float]] = field(default_factory=list)
|
||||
delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list)
|
||||
|
||||
async def run(self, script: str, *, cwd=None, env=None, timeout=30.0):
|
||||
self.run_calls.append((script, cwd, env, timeout))
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd=None,
|
||||
env=None,
|
||||
timeout=30.0,
|
||||
mode: JobMode = JobMode.PTY,
|
||||
):
|
||||
self.run_calls.append((script, cwd, env, timeout, mode))
|
||||
if isinstance(self.run_result, Exception):
|
||||
raise self.run_result
|
||||
return self.run_result
|
||||
@@ -85,7 +94,20 @@ def test_commands_apply_runtime_layout_and_home_environment() -> None:
|
||||
assert result.output == "ok"
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert client.run_calls == [("pwd", "/workspace/reports", {"TOKEN": "value", "HOME": "/home/binding"}, 2.5)]
|
||||
assert client.run_calls == [
|
||||
("pwd", "/workspace/reports", {"TOKEN": "value", "HOME": "/home/binding"}, 2.5, JobMode.PTY)
|
||||
]
|
||||
|
||||
|
||||
def test_commands_forward_stdio_mode() -> None:
|
||||
client = _Client()
|
||||
|
||||
async def scenario() -> None:
|
||||
commands = ShellctlCommands(_client(client))
|
||||
await commands.run("printf result", timeout=2.5, mode="stdio")
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert client.run_calls == [("printf result", None, None, 2.5, JobMode.STDIO)]
|
||||
|
||||
|
||||
def test_commands_reject_cwd_outside_runtime_layout() -> None:
|
||||
|
||||
@@ -26,6 +26,7 @@ from dify_agent.layers.shell.layer import (
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellCommandResult,
|
||||
ShellCommandStatus,
|
||||
ShellExecutionMode,
|
||||
ShellProviderError,
|
||||
)
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@@ -113,6 +114,7 @@ class RunCall:
|
||||
cwd: str | None
|
||||
env: Mapping[str, str] | None
|
||||
timeout: float
|
||||
mode: ShellExecutionMode = "pty"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -167,8 +169,16 @@ class FakeCommands:
|
||||
interrupt_calls: list[InterruptCall] = field(default_factory=list)
|
||||
delete_calls: list[DeleteCall] = field(default_factory=list)
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, timeout: float):
|
||||
self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
):
|
||||
self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout, mode=mode))
|
||||
if self.run_handler is None:
|
||||
raise AssertionError("Unexpected run() call")
|
||||
return self.run_handler(script, cwd, env, timeout)
|
||||
@@ -499,6 +509,7 @@ def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> No
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert layer.runtime_state.job_offsets == {"user-job": 34}
|
||||
assert commands.run_calls[0].mode == "pty"
|
||||
assert commands.tail_calls == [TailCall(job_id="user-job"), TailCall(job_id="user-job")]
|
||||
|
||||
|
||||
@@ -936,6 +947,7 @@ def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert events == ["run", "read_output", "wait"]
|
||||
assert commands.run_calls[0].mode == "stdio"
|
||||
assert [call.job_id for call in commands.delete_calls] == ["remote-job"]
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellExecutionMode
|
||||
from dify_agent.runtime.command_runner import execute_complete_with_commands
|
||||
|
||||
|
||||
@@ -16,11 +16,20 @@ class _BlockingCommands:
|
||||
wait_forever: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
deletes: list[tuple[str, bool]] = field(default_factory=list)
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None, env: dict[str, str] | None, timeout: float):
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None,
|
||||
env: dict[str, str] | None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
):
|
||||
assert script == "long-running"
|
||||
assert cwd == "/workspace"
|
||||
assert env == {"HOME": "/home/agent"}
|
||||
assert timeout > 0
|
||||
assert mode == "stdio"
|
||||
return ShellCommandResult(
|
||||
job_id="job-1",
|
||||
status="running",
|
||||
@@ -66,6 +75,7 @@ async def test_cancellation_deletes_job_returned_before_blocking_wait() -> None:
|
||||
env={"HOME": "/home/agent"},
|
||||
timeout=60.0,
|
||||
max_output_bytes=4096,
|
||||
mode="stdio",
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ import shlex
|
||||
from typing import Mapping
|
||||
|
||||
import pytest
|
||||
from shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
from shellctl.shared import DeleteJobResponse, JobMode, JobResult, JobStatusName, JobStatusView
|
||||
|
||||
from dify_agent.runtime_backend import (
|
||||
BindingCreateError,
|
||||
@@ -22,6 +22,7 @@ class _RunCall:
|
||||
commands: tuple[tuple[str, ...], ...]
|
||||
cwd: str | None
|
||||
env: Mapping[str, str] | None
|
||||
mode: JobMode
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -40,12 +41,13 @@ class _Client:
|
||||
cwd: str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
timeout: float = 10.0,
|
||||
mode: JobMode = JobMode.PTY,
|
||||
) -> JobResult:
|
||||
del timeout
|
||||
commands = tuple(
|
||||
tuple(shlex.split(line)) for line in script.splitlines() if line.strip() and line.strip() != "set -eu"
|
||||
)
|
||||
self.runs.append(_RunCall(commands=commands, cwd=cwd, env=env))
|
||||
self.runs.append(_RunCall(commands=commands, cwd=cwd, env=env, mode=mode))
|
||||
return JobResult(
|
||||
job_id=f"job-{len(self.runs)}",
|
||||
status=JobStatusName.EXITED,
|
||||
@@ -165,6 +167,7 @@ async def test_local_binding_create_materializes_home_and_new_workspace() -> Non
|
||||
assert ("mkdir", "-p", "/homes/binding-1") in factory.commands
|
||||
assert ("cp", "-a", "/snapshots/home-home-1/.", "/homes/binding-1/") in factory.commands
|
||||
assert ("chmod", "700", "/homes/binding-1", "/workspaces/workspace-1") in factory.commands
|
||||
assert all(run.mode is JobMode.STDIO for run in factory.runs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -245,6 +248,7 @@ async def test_local_binding_acquire_scopes_commands_to_materialized_home_and_wo
|
||||
pwd_run = next(run for run in factory.runs if run.commands == (("pwd",),))
|
||||
assert pwd_run.cwd == "/workspaces/workspace-1"
|
||||
assert pwd_run.env == {"HOME": "/homes/binding-1"}
|
||||
assert pwd_run.mode is JobMode.PTY
|
||||
with pytest.raises(ValueError, match="outside this RuntimeLease"):
|
||||
await lease.commands.run("cat secret", cwd="/homes/other", timeout=10.0)
|
||||
await backend.release(lease)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellCommandStatus
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellCommandStatus, ShellExecutionMode
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol
|
||||
from dify_agent.runtime_backend.protocols import RuntimeLayout
|
||||
from dify_agent.runtime_backend.shellctl import (
|
||||
@@ -43,6 +43,7 @@ class _FakeCommands:
|
||||
wait_error: Exception | None = None
|
||||
delete_error: Exception | None = None
|
||||
delete_calls: list[tuple[str, bool]] = field(default_factory=list)
|
||||
run_modes: list[ShellExecutionMode] = field(default_factory=list)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -51,8 +52,10 @@ class _FakeCommands:
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult:
|
||||
del script, cwd, env, timeout
|
||||
self.run_modes.append(mode)
|
||||
return self.initial
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float) -> ShellCommandResult:
|
||||
@@ -169,6 +172,7 @@ async def test_control_command_success_is_preserved_when_delete_fails(
|
||||
result = await run_shellctl_control_command(commands, "true")
|
||||
|
||||
assert result.output == "ok"
|
||||
assert commands.run_modes == ["stdio"]
|
||||
assert commands.delete_calls == [("job-1", True)]
|
||||
assert "delete failed" in caplog.text
|
||||
|
||||
@@ -188,4 +192,5 @@ async def test_control_command_error_is_preserved_when_delete_also_fails(
|
||||
_ = await run_shellctl_control_command(commands, "false")
|
||||
|
||||
assert commands.delete_calls == [("job-1", True)]
|
||||
assert commands.run_modes == ["stdio"]
|
||||
assert "delete failed" in caplog.text
|
||||
|
||||
@@ -15,7 +15,7 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellExecutionMode, ShellProviderError
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.protocol import BindingFileDownloadRequest, BindingFileListRequest, BindingFileReadRequest
|
||||
from dify_agent.runtime_backend import BindingAcquireError, BindingLostError, RuntimeLayout, RuntimeLease
|
||||
@@ -38,7 +38,16 @@ class _Commands:
|
||||
calls: list[tuple[str, str | None, dict[str, str] | None, float]] = field(default_factory=list)
|
||||
deletes: list[str] = field(default_factory=list)
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env=None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult:
|
||||
assert mode == "stdio"
|
||||
self.calls.append((script, cwd, env, timeout))
|
||||
output = self.outputs.pop(0)
|
||||
exit_code = self.exit_codes.pop(0) if self.exit_codes else 0
|
||||
@@ -77,7 +86,16 @@ class _ProviderErrorCommands(_Commands):
|
||||
phase: Literal["run", "wait"] = "run"
|
||||
error_code: str = "timeout"
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env=None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult:
|
||||
assert mode == "stdio"
|
||||
self.calls.append((script, cwd, env, timeout))
|
||||
if self.phase == "run":
|
||||
raise ShellProviderError("shell provider failed", code=self.error_code)
|
||||
@@ -99,7 +117,16 @@ class _ProviderErrorCommands(_Commands):
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _LocalCommands(_Commands):
|
||||
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env=None,
|
||||
timeout: float,
|
||||
mode: ShellExecutionMode = "pty",
|
||||
) -> ShellCommandResult:
|
||||
assert mode == "stdio"
|
||||
self.calls.append((script, cwd, env, timeout))
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
script,
|
||||
|
||||
@@ -10,6 +10,7 @@ from shellctl.client import sdk as shellctl_sdk
|
||||
from shellctl.shared import (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
HealthResponse,
|
||||
JobMode,
|
||||
JobStatusName,
|
||||
)
|
||||
|
||||
@@ -29,12 +30,14 @@ class ForcedDeleteKwargs(TypedDict, total=False):
|
||||
cwd="/tmp",
|
||||
env={"HELLO": "world"},
|
||||
timeout=12,
|
||||
mode=JobMode.STDIO,
|
||||
),
|
||||
"/v1/jobs/run",
|
||||
{
|
||||
"script": "printf ready\\n",
|
||||
"cwd": "/tmp",
|
||||
"env": {"HELLO": "world"},
|
||||
"mode": "stdio",
|
||||
"timeout": 12.0,
|
||||
"output_limit": 4096,
|
||||
"idle_flush_seconds": 0.25,
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import ValidationError
|
||||
|
||||
from shellctl.shared import (
|
||||
JOB_ID_ALPHABET,
|
||||
JobMode,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
RunJobRequest,
|
||||
SHELL_TOOL_HARD_TIMEOUT_SECONDS,
|
||||
@@ -18,6 +19,13 @@ from shellctl.shared import (
|
||||
)
|
||||
|
||||
|
||||
def test_run_job_request_defaults_to_pty_and_rejects_unknown_mode() -> None:
|
||||
assert RunJobRequest(script="true").mode is JobMode.PTY
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
RunJobRequest(script="true", mode="stdout") # pyright: ignore[reportArgumentType]
|
||||
|
||||
|
||||
def test_shell_tool_timeout_budget_has_one_source_of_truth() -> None:
|
||||
assert MAX_WAIT_TIMEOUT_SECONDS == SHELL_TOOL_HARD_TIMEOUT_SECONDS == 300
|
||||
assert SHELL_TOOL_HTTP_TIMEOUT_GRACE_SECONDS == 10
|
||||
|
||||
Reference in New Issue
Block a user