Files
isphere-ai-bridge/internal/helperclient/client.go
2026-07-05 19:19:07 +08:00

223 lines
4.3 KiB
Go

package helperclient
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"time"
)
const (
ErrorCodeMissingHelper = "MISSING_HELPER"
ErrorCodeTimeout = "TIMEOUT"
ErrorCodeBadJSON = "BAD_JSON"
ErrorCodeProcessFailed = "PROCESS_FAILED"
)
type Client struct {
HelperPath string
Timeout time.Duration
}
type ClientError struct {
Code string
Message string
HelperPath string
Stderr string
Stdout string
ExitCode int
Cause error
}
func (e *ClientError) Error() string {
if e == nil {
return ""
}
if e.HelperPath == "" {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
return fmt.Sprintf("%s: %s: %s", e.Code, e.HelperPath, e.Message)
}
func (e *ClientError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func (c Client) Call(ctx context.Context, op string, args map[string]any) (*HelperResponse, error) {
if ctx == nil {
ctx = context.Background()
}
helperPath := c.HelperPath
if helperPath == "" {
helperPath = defaultHelperPath()
}
if err := ensureHelperFile(helperPath); err != nil {
return nil, &ClientError{
Code: ErrorCodeMissingHelper,
Message: "helper executable is not available",
HelperPath: helperPath,
Cause: err,
}
}
callCtx := ctx
var cancel context.CancelFunc
if c.Timeout > 0 {
callCtx, cancel = context.WithTimeout(ctx, c.Timeout)
defer cancel()
}
if args == nil {
args = map[string]any{}
}
request := HelperRequest{
Protocol: HelperProtocolV1,
RequestID: newRequestID(),
Op: op,
TimeoutMS: timeoutMillis(callCtx),
Args: args,
}
requestBytes, err := json.Marshal(request)
if err != nil {
return nil, &ClientError{
Code: ErrorCodeProcessFailed,
Message: "marshal helper request",
HelperPath: helperPath,
Cause: err,
}
}
cmd := exec.CommandContext(callCtx, helperPath, "--json")
cmd.Stdin = bytes.NewReader(requestBytes)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
stderrText := stderr.String()
stdoutText := stdout.String()
if callCtx.Err() != nil {
return nil, &ClientError{
Code: ErrorCodeTimeout,
Message: "helper process timed out",
HelperPath: helperPath,
Stderr: stderrText,
Stdout: stdoutText,
ExitCode: exitCode(runErr),
Cause: callCtx.Err(),
}
}
if runErr != nil {
return nil, &ClientError{
Code: ErrorCodeProcessFailed,
Message: "helper process failed",
HelperPath: helperPath,
Stderr: stderrText,
Stdout: stdoutText,
ExitCode: exitCode(runErr),
Cause: runErr,
}
}
var response HelperResponse
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
return nil, &ClientError{
Code: ErrorCodeBadJSON,
Message: "helper stdout was not valid response JSON",
HelperPath: helperPath,
Stderr: stderrText,
Stdout: stdoutText,
Cause: err,
}
}
return &response, nil
}
func ensureHelperFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.IsDir() {
return fmt.Errorf("helper path is a directory")
}
return nil
}
func defaultHelperPath() string {
return filepath.Join(repoRoot(), "runs", "win-helper", "ISphereWinHelper.exe")
}
func repoRoot() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
dir := wd
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return wd
}
dir = parent
}
}
func timeoutMillis(ctx context.Context) int {
deadline, ok := ctx.Deadline()
if !ok {
return 0
}
remaining := time.Until(deadline)
if remaining <= 0 {
return 1
}
millis := remaining.Milliseconds()
if millis < 1 {
return 1
}
if millis > int64(math.MaxInt) {
return math.MaxInt
}
return int(millis)
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
func newRequestID() string {
var bytes [16]byte
if _, err := rand.Read(bytes[:]); err == nil {
return hex.EncodeToString(bytes[:])
}
return fmt.Sprintf("req-%d", time.Now().UnixNano())
}