You've already forked agentic-coding-workflow
启动项目逻辑
This commit is contained in:
@@ -147,3 +147,31 @@ mcp:
|
|||||||
rate_limit:
|
rate_limit:
|
||||||
per_token_per_minute: 100
|
per_token_per_minute: 100
|
||||||
global_per_minute: 1000
|
global_per_minute: 1000
|
||||||
|
|
||||||
|
# Run profiles (后端托管 workspace 仓库启动) configuration
|
||||||
|
run:
|
||||||
|
enabled: true
|
||||||
|
kill_grace: 5s # stop 时优雅终止宽限,超时后整树 SIGKILL
|
||||||
|
out_buffer_lines: 1000 # 单次运行的合并 stdout+stderr 内存环形缓冲行数
|
||||||
|
out_tail_bytes: 2000 # crash 时落 last_error 的尾部字节上限
|
||||||
|
ws_send_buffer: 256 # 每个 WS 日志订阅者的发送缓冲
|
||||||
|
shutdown_grace: 30s # 服务关停时整体终止所有 run 进程的总时限
|
||||||
|
# 透传给被托管命令的宿主环境变量白名单。APP_MASTER_KEY / DB DSN / git 凭据
|
||||||
|
# 等敏感变量绝不应加入此名单。
|
||||||
|
env_whitelist:
|
||||||
|
- PATH
|
||||||
|
- HOME
|
||||||
|
- LANG
|
||||||
|
- LC_ALL
|
||||||
|
- TZ
|
||||||
|
- USER
|
||||||
|
- SHELL
|
||||||
|
- SystemRoot
|
||||||
|
- TEMP
|
||||||
|
- TMP
|
||||||
|
- USERPROFILE
|
||||||
|
- APPDATA
|
||||||
|
- LOCALAPPDATA
|
||||||
|
- PATHEXT
|
||||||
|
- ComSpec
|
||||||
|
- NUMBER_OF_PROCESSORS
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||||
|
procgrp "github.com/yan1h/agent-coding-workflow/internal/infra/proc"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -95,6 +96,7 @@ type Process struct {
|
|||||||
WorktreeID *uuid.UUID // 子 worktree 时填,用于 release
|
WorktreeID *uuid.UUID // 子 worktree 时填,用于 release
|
||||||
|
|
||||||
Cmd *exec.Cmd
|
Cmd *exec.Cmd
|
||||||
|
group procgrp.Group // 进程树句柄(整树终止),Spawn 时 Prepare+Adopt
|
||||||
Stdin io.WriteCloser
|
Stdin io.WriteCloser
|
||||||
Stdout io.ReadCloser
|
Stdout io.ReadCloser
|
||||||
Stderr io.ReadCloser
|
Stderr io.ReadCloser
|
||||||
@@ -173,6 +175,11 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
cmd.Dir = sess.CwdPath
|
cmd.Dir = sess.CwdPath
|
||||||
cmd.Env = osEnv
|
cmd.Env = osEnv
|
||||||
|
|
||||||
|
// 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后
|
||||||
|
// (Windows Job Object)。终止时整树清理,避免 agent 派生的子孙进程残留。
|
||||||
|
group := procgrp.NewGroup()
|
||||||
|
group.Prepare(cmd)
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||||
@@ -189,6 +196,10 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("start: %w", err)
|
return nil, fmt.Errorf("start: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := group.Adopt(cmd); err != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return nil, fmt.Errorf("adopt process group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var decider handlers.PermissionDecider
|
var decider handlers.PermissionDecider
|
||||||
if s.permSvc != nil {
|
if s.permSvc != nil {
|
||||||
@@ -215,6 +226,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
UserID: sess.UserID,
|
UserID: sess.UserID,
|
||||||
IsMain: sess.IsMainWorktree,
|
IsMain: sess.IsMainWorktree,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
|
group: group,
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
@@ -307,9 +319,9 @@ func (s *Supervisor) Kill(ctx context.Context, sid uuid.UUID, grace time.Duratio
|
|||||||
|
|
||||||
proc.KilledByUs.Store(true)
|
proc.KilledByUs.Store(true)
|
||||||
|
|
||||||
// 平台差异:Unix 走 SIGTERM + grace + 强 Kill;Windows 直接 Kill。
|
// 整树终止:Unix 走 SIGTERM(-pgid) + grace + SIGKILL(-pgid);Windows 关闭
|
||||||
// 实现见 supervisor_unix.go / supervisor_windows.go。
|
// Job 句柄触发 KILL_ON_JOB_CLOSE。实现下沉至 internal/infra/proc。
|
||||||
s.unixGracefulTerm(proc, grace)
|
proc.group.TerminateGroup(proc.done, grace)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.done:
|
case <-proc.done:
|
||||||
@@ -375,6 +387,10 @@ func (s *Supervisor) monitorProcess(proc *Process) {
|
|||||||
delete(s.procs, proc.SessionID)
|
delete(s.procs, proc.SessionID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// 释放进程树句柄(Windows 关闭 Job handle;Unix no-op)。done 已 close、
|
||||||
|
// 进程已 Wait 返回,此处 Close 不影响 onExit-must-not-relock 不变量。
|
||||||
|
proc.group.Close()
|
||||||
|
|
||||||
status := SessionExited
|
status := SessionExited
|
||||||
if !proc.KilledByUs.Load() {
|
if !proc.KilledByUs.Load() {
|
||||||
status = SessionCrashed
|
status = SessionCrashed
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package acp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// unixGracefulTerm 在 Unix 平台上先发 SIGTERM,等待 grace 时间内退出;
|
|
||||||
// 否则 Process.Kill。done channel 由 monitorProcess 关闭。
|
|
||||||
func (s *Supervisor) unixGracefulTerm(proc *Process, grace time.Duration) {
|
|
||||||
if proc.Cmd.Process == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = proc.Cmd.Process.Signal(syscall.SIGTERM)
|
|
||||||
select {
|
|
||||||
case <-proc.done:
|
|
||||||
return
|
|
||||||
case <-time.After(grace):
|
|
||||||
_ = proc.Cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package acp
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// unixGracefulTerm 在 Windows 上没有可靠的 SIGTERM 等价信号(CTRL_BREAK 仅
|
|
||||||
// 对 console 子进程生效)。直接 Process.Kill,grace 参数不参与,仅签名一致。
|
|
||||||
func (s *Supervisor) unixGracefulTerm(proc *Process, grace time.Duration) {
|
|
||||||
if proc.Cmd.Process == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = proc.Cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
+36
-1
@@ -46,6 +46,7 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
||||||
mcp "github.com/yan1h/agent-coding-workflow/internal/mcp"
|
mcp "github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
|
runmod "github.com/yan1h/agent-coding-workflow/internal/run"
|
||||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/user"
|
"github.com/yan1h/agent-coding-workflow/internal/user"
|
||||||
@@ -61,7 +62,8 @@ type App struct {
|
|||||||
dispatcher *notify.Dispatcher
|
dispatcher *notify.Dispatcher
|
||||||
jobs *jobs.Module
|
jobs *jobs.Module
|
||||||
jobsCancel context.CancelFunc
|
jobsCancel context.CancelFunc
|
||||||
acpSup *acp.Supervisor // ACP supervisor — ShutdownAll on Run shutdown
|
acpSup *acp.Supervisor // ACP supervisor — ShutdownAll on Run shutdown
|
||||||
|
runSup *runmod.RunSupervisor // run profiles supervisor — ShutdownAll on Run shutdown
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。
|
// New 把所有模块按启动顺序拼装好。任何阶段失败都会返回错误,不留半成品资源。
|
||||||
@@ -396,6 +398,31 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
SendBuffer: cfg.Acp.WSSendBuffer,
|
SendBuffer: cfg.Acp.WSSendBuffer,
|
||||||
}).Mount(r)
|
}).Mount(r)
|
||||||
|
|
||||||
|
// ===== run profiles module wiring =====
|
||||||
|
// 进程托管:每个 workspace 的 run profile 作为长驻进程在 main_path 拉起。
|
||||||
|
// 复用 workspace.ProjectAccess(pa) 做 owner+admin 写鉴权。WS 心跳沿用 acp 的
|
||||||
|
// 全局默认值(同类参数,避免新增配置面)。
|
||||||
|
runRepo := runmod.NewPostgresRepository(pool)
|
||||||
|
runSup := runmod.NewSupervisor(runRepo, runmod.SupervisorConfig{
|
||||||
|
KillGrace: cfg.Run.KillGrace,
|
||||||
|
OutBufferLines: cfg.Run.OutBufferLines,
|
||||||
|
OutTailBytes: cfg.Run.OutTailBytes,
|
||||||
|
WSSendBuffer: cfg.Run.WSSendBuffer,
|
||||||
|
ShutdownGrace: cfg.Run.ShutdownGrace,
|
||||||
|
}, log)
|
||||||
|
runSvc := runmod.NewService(runRepo, wsRepo, runSup, pa, auditRec, enc, runmod.ServiceConfig{
|
||||||
|
EnvWhitelist: cfg.Run.EnvWhitelist,
|
||||||
|
KillGrace: cfg.Run.KillGrace,
|
||||||
|
OutTailBytes: cfg.Run.OutTailBytes,
|
||||||
|
}, log)
|
||||||
|
if cfg.Run.Enabled {
|
||||||
|
runmod.NewHandler(runSvc, userSvc, userAdminAdapter{svc: userSvc}, runmod.WSConfig{
|
||||||
|
PingInterval: cfg.Acp.WSPingInterval,
|
||||||
|
PongTimeout: cfg.Acp.WSPongTimeout,
|
||||||
|
SendBuffer: cfg.Run.WSSendBuffer,
|
||||||
|
}).Mount(r)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== jobs module wiring =====
|
// ===== jobs module wiring =====
|
||||||
jobsRepo := jobs.NewPostgresRepository(pool)
|
jobsRepo := jobs.NewPostgresRepository(pool)
|
||||||
|
|
||||||
@@ -577,6 +604,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
jobs: jobsModule,
|
jobs: jobsModule,
|
||||||
jobsCancel: jobsCancel,
|
jobsCancel: jobsCancel,
|
||||||
acpSup: acpSup,
|
acpSup: acpSup,
|
||||||
|
runSup: runSup,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,6 +634,10 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
if a.acpSup != nil {
|
if a.acpSup != nil {
|
||||||
a.acpSup.ShutdownAll(shutCtx)
|
a.acpSup.ShutdownAll(shutCtx)
|
||||||
}
|
}
|
||||||
|
// Stop run-profile subprocesses (整树 kill) on shutdown.
|
||||||
|
if a.runSup != nil {
|
||||||
|
a.runSup.ShutdownAll(shutCtx)
|
||||||
|
}
|
||||||
// Stop jobs module first so no in-flight worker can dispatch new
|
// Stop jobs module first so no in-flight worker can dispatch new
|
||||||
// notifications after we begin to drain the dispatcher.
|
// notifications after we begin to drain the dispatcher.
|
||||||
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
||||||
@@ -631,6 +663,9 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
if a.acpSup != nil {
|
if a.acpSup != nil {
|
||||||
a.acpSup.ShutdownAll(drainCtx)
|
a.acpSup.ShutdownAll(drainCtx)
|
||||||
}
|
}
|
||||||
|
if a.runSup != nil {
|
||||||
|
a.runSup.ShutdownAll(drainCtx)
|
||||||
|
}
|
||||||
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
if a.jobs != nil && a.cfg.Jobs.Enabled {
|
||||||
a.jobsCancel()
|
a.jobsCancel()
|
||||||
stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second)
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), a.cfg.Jobs.ShutdownGrace+5*time.Second)
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Config struct {
|
|||||||
Notify NotifyConfig `mapstructure:"notify"`
|
Notify NotifyConfig `mapstructure:"notify"`
|
||||||
Acp AcpConfig `mapstructure:"acp"`
|
Acp AcpConfig `mapstructure:"acp"`
|
||||||
MCP MCPConfig `mapstructure:"mcp"`
|
MCP MCPConfig `mapstructure:"mcp"`
|
||||||
|
Run RunConfig `mapstructure:"run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPConfig 描述 HTTP 服务监听参数。
|
// HTTPConfig 描述 HTTP 服务监听参数。
|
||||||
@@ -200,6 +201,20 @@ type AcpConfig struct {
|
|||||||
PermissionTimeout time.Duration `mapstructure:"permission_timeout"`
|
PermissionTimeout time.Duration `mapstructure:"permission_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunConfig 控制 workspace run profiles 模块:进程托管的终止宽限、日志环形缓冲
|
||||||
|
// 容量、WS 发送缓冲、关停宽限,以及传递给被托管命令的宿主环境变量白名单。
|
||||||
|
type RunConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
KillGrace time.Duration `mapstructure:"kill_grace"`
|
||||||
|
OutBufferLines int `mapstructure:"out_buffer_lines"`
|
||||||
|
OutTailBytes int `mapstructure:"out_tail_bytes"`
|
||||||
|
WSSendBuffer int `mapstructure:"ws_send_buffer"`
|
||||||
|
ShutdownGrace time.Duration `mapstructure:"shutdown_grace"`
|
||||||
|
// EnvWhitelist 是允许从宿主进程透传给被托管命令的环境变量名白名单。
|
||||||
|
// APP_MASTER_KEY、DB DSN、git 凭据等敏感变量绝不应出现在此名单中。
|
||||||
|
EnvWhitelist []string `mapstructure:"env_whitelist"`
|
||||||
|
}
|
||||||
|
|
||||||
// MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。
|
// MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
@@ -285,6 +300,17 @@ func Load(configFile string) (*Config, error) {
|
|||||||
v.SetDefault("mcp.system_token_ttl", "24h")
|
v.SetDefault("mcp.system_token_ttl", "24h")
|
||||||
v.SetDefault("mcp.rate_limit.per_token_per_minute", 100)
|
v.SetDefault("mcp.rate_limit.per_token_per_minute", 100)
|
||||||
v.SetDefault("mcp.rate_limit.global_per_minute", 1000)
|
v.SetDefault("mcp.rate_limit.global_per_minute", 1000)
|
||||||
|
v.SetDefault("run.enabled", true)
|
||||||
|
v.SetDefault("run.kill_grace", "5s")
|
||||||
|
v.SetDefault("run.out_buffer_lines", 1000)
|
||||||
|
v.SetDefault("run.out_tail_bytes", 2000)
|
||||||
|
v.SetDefault("run.ws_send_buffer", 256)
|
||||||
|
v.SetDefault("run.shutdown_grace", "30s")
|
||||||
|
v.SetDefault("run.env_whitelist", []string{
|
||||||
|
"PATH", "HOME", "LANG", "LC_ALL", "TZ", "USER", "SHELL",
|
||||||
|
"SystemRoot", "TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||||
|
"PATHEXT", "ComSpec", "NUMBER_OF_PROCESSORS",
|
||||||
|
})
|
||||||
|
|
||||||
if configFile != "" {
|
if configFile != "" {
|
||||||
v.SetConfigFile(configFile)
|
v.SetConfigFile(configFile)
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ const (
|
|||||||
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
||||||
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
||||||
CodeMcpRateLimited Code = "mcp.rate_limited"
|
CodeMcpRateLimited Code = "mcp.rate_limited"
|
||||||
|
|
||||||
|
// Run 域(workspace run profiles)
|
||||||
|
CodeRunProfileNotFound Code = "run.profile_not_found"
|
||||||
|
CodeRunProfileSlugTaken Code = "run.profile_slug_taken"
|
||||||
|
CodeRunProfileAlreadyRunning Code = "run.profile_already_running"
|
||||||
|
CodeRunProfileNotRunning Code = "run.profile_not_running"
|
||||||
|
CodeRunProfileDisabled Code = "run.profile_disabled"
|
||||||
|
CodeRunSpawnFailed Code = "run.spawn_failed"
|
||||||
|
CodeRunCommandInvalid Code = "run.command_invalid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppError is the application's structured error type. It carries a stable
|
// AppError is the application's structured error type. It carries a stable
|
||||||
@@ -239,6 +248,15 @@ func HTTPStatus(code Code) int {
|
|||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case CodeMcpRateLimited:
|
case CodeMcpRateLimited:
|
||||||
return http.StatusTooManyRequests
|
return http.StatusTooManyRequests
|
||||||
|
case CodeRunProfileNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case CodeRunProfileSlugTaken, CodeRunProfileAlreadyRunning,
|
||||||
|
CodeRunProfileNotRunning, CodeRunProfileDisabled:
|
||||||
|
return http.StatusConflict
|
||||||
|
case CodeRunCommandInvalid:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case CodeRunSpawnFailed:
|
||||||
|
return http.StatusBadGateway
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package proc 提供跨平台的"进程组/进程树"终止抽象。一个 Group 把某个进程及其
|
||||||
|
// 派生的所有子孙进程作为单一单元管理,使 stop/restart/crash 时能整树清理,避免
|
||||||
|
// dev server(如 pnpm dev → node/vite/esbuild)留下占用端口的孤儿进程。
|
||||||
|
//
|
||||||
|
// 用法与 *exec.Cmd 生命周期对齐:
|
||||||
|
//
|
||||||
|
// g := proc.NewGroup()
|
||||||
|
// g.Prepare(cmd) // cmd.Start() 之前:Unix 设置 SysProcAttr.Setpgid
|
||||||
|
// cmd.Start()
|
||||||
|
// g.Adopt(cmd) // cmd.Start() 之后:Windows 创建 Job 并 Assign
|
||||||
|
// ...
|
||||||
|
// g.TerminateGroup(done, grace) // 整树优雅终止
|
||||||
|
// g.Close() // 释放平台句柄
|
||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group 抽象"一个进程及其所有子孙进程"作为单一单元管理。
|
||||||
|
// 非并发安全:调用方需保证 Prepare/Adopt/TerminateGroup/Close 的串行使用
|
||||||
|
// (RunSupervisor / ACP Supervisor 已用各自的锁与 done channel 保证)。
|
||||||
|
type Group interface {
|
||||||
|
// Prepare 在 cmd.Start() 之前设置平台属性(Unix: SysProcAttr.Setpgid)。
|
||||||
|
Prepare(cmd *exec.Cmd)
|
||||||
|
// Adopt 在 cmd.Start() 之后接管已启动的进程(Windows: 创建 Job 并
|
||||||
|
// AssignProcessToJobObject)。Unix 为记录 pgid。必须在子进程派生前调用。
|
||||||
|
Adopt(cmd *exec.Cmd) error
|
||||||
|
// TerminateGroup 优雅终止整组:Unix 先 SIGTERM(-pgid) 等 grace 再 SIGKILL(-pgid);
|
||||||
|
// Windows CloseHandle(job) 触发 KILL_ON_JOB_CLOSE 整树终止。
|
||||||
|
// done 由调用方(monitor goroutine)关闭,用于感知优雅退出、提前返回。
|
||||||
|
TerminateGroup(done <-chan struct{}, grace time.Duration)
|
||||||
|
// Close 释放平台句柄(Windows: 关闭 Job handle;Unix: no-op)。幂等。
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGroup 按编译期 build tag 返回 unixGroup / windowsGroup。
|
||||||
|
func NewGroup() Group { return newGroup() }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sleeperCmd 返回一个会阻塞约 d 秒的跨平台命令。
|
||||||
|
func sleeperCmd(d int) *exec.Cmd {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return exec.Command("cmd", "/c", fmt.Sprintf("timeout /t %d /nobreak >nul", d))
|
||||||
|
}
|
||||||
|
return exec.Command("sh", "-c", fmt.Sprintf("sleep %d", d))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGroup_TerminateKillsStartedProcess 验证完整生命周期:Prepare → Start →
|
||||||
|
// Adopt → TerminateGroup 能在 grace 内终止一个本应长跑的进程。
|
||||||
|
func TestGroup_TerminateKillsStartedProcess(t *testing.T) {
|
||||||
|
g := NewGroup()
|
||||||
|
cmd := sleeperCmd(30)
|
||||||
|
g.Prepare(cmd)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := g.Adopt(cmd); err != nil {
|
||||||
|
t.Fatalf("adopt: %v", err)
|
||||||
|
}
|
||||||
|
defer g.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
g.TerminateGroup(done, 2*time.Second)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// 进程已终止,符合预期。
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("process not terminated within 10s after TerminateGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewGroup_NotNil 守护 build tag 选择逻辑:任意平台都应返回非 nil Group。
|
||||||
|
func TestNewGroup_NotNil(t *testing.T) {
|
||||||
|
if NewGroup() == nil {
|
||||||
|
t.Fatal("NewGroup returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unixGroup 用 POSIX 进程组实现整树终止:子进程经 Setpgid 成为新进程组组长
|
||||||
|
// (pgid == pid),向负 pgid 发信号即覆盖整个进程组的所有子孙。
|
||||||
|
type unixGroup struct {
|
||||||
|
pgid int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroup() Group { return &unixGroup{} }
|
||||||
|
|
||||||
|
func (g *unixGroup) Prepare(cmd *exec.Cmd) {
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr.Setpgid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *unixGroup) Adopt(cmd *exec.Cmd) error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return errors.New("proc: Adopt before Start")
|
||||||
|
}
|
||||||
|
// Setpgid 使子进程成为新进程组组长,pgid == pid。
|
||||||
|
g.pgid = cmd.Process.Pid
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *unixGroup) TerminateGroup(done <-chan struct{}, grace time.Duration) {
|
||||||
|
if g.pgid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 向负 pgid 发信号 = 向整个进程组(含所有子孙)发信号。忽略 ESRCH 等错误。
|
||||||
|
_ = syscall.Kill(-g.pgid, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-time.After(grace):
|
||||||
|
_ = syscall.Kill(-g.pgid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *unixGroup) Close() {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnixGroup_KillsChildProcess 验证整树清理:父 shell 派生一个后台 sleep
|
||||||
|
// 子进程并打印其 PID,TerminateGroup 后该子进程(孙级)也应被 kill(-pgid) 回收,
|
||||||
|
// 证明终止覆盖整个进程组而非仅组长。
|
||||||
|
func TestUnixGroup_KillsChildProcess(t *testing.T) {
|
||||||
|
g := NewGroup()
|
||||||
|
// `sleep 30 &` 后台子进程;`echo $!` 打印其 PID;`wait` 让 shell 阻塞存活。
|
||||||
|
cmd := exec.Command("sh", "-c", "sleep 30 & echo $!; wait")
|
||||||
|
g.Prepare(cmd)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stdout pipe: %v", err)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := g.Adopt(cmd); err != nil {
|
||||||
|
t.Fatalf("adopt: %v", err)
|
||||||
|
}
|
||||||
|
defer g.Close()
|
||||||
|
|
||||||
|
var childPID int
|
||||||
|
if _, err := fmt.Fscan(stdout, &childPID); err != nil {
|
||||||
|
t.Fatalf("read child pid: %v", err)
|
||||||
|
}
|
||||||
|
if childPID <= 0 {
|
||||||
|
t.Fatalf("invalid child pid: %d", childPID)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
g.TerminateGroup(done, 2*time.Second)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("parent not terminated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给内核一点时间回收子进程,然后用 kill(pid, 0) 探测:返回错误(ESRCH)=已死。
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
if err := syscall.Kill(childPID, 0); err != nil {
|
||||||
|
return // 子进程已死,符合预期。
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("child process %d still alive after group terminate", childPID)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package proc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// windowsGroup 用 Job Object 实现整树终止:把进程纳入一个设置了
|
||||||
|
// KILL_ON_JOB_CLOSE 的 Job,此后该进程派生的所有子孙自动归入同一 Job,
|
||||||
|
// 关闭 Job 句柄即整树终止。优于 Process.Kill(只杀单 PID,漏 node 等子进程)。
|
||||||
|
type windowsGroup struct {
|
||||||
|
job windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroup() Group { return &windowsGroup{} }
|
||||||
|
|
||||||
|
// Prepare 在 Windows 为 no-op:Job 须在进程 Start 之后才能 Assign。
|
||||||
|
func (g *windowsGroup) Prepare(cmd *exec.Cmd) {}
|
||||||
|
|
||||||
|
// Adopt 必须在 cmd.Start() 之后、子进程派生之前调用。竞态窗口:父进程 exec 后
|
||||||
|
// 到首个子进程派生之间存在微秒级窗口(Go 无 pre-exec hook 无法完全消除),
|
||||||
|
// 实践风险极小,文档化为已知限制。
|
||||||
|
func (g *windowsGroup) Adopt(cmd *exec.Cmd) error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return errors.New("proc: Adopt before Start")
|
||||||
|
}
|
||||||
|
job, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var info windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||||
|
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||||
|
if _, err := windows.SetInformationJobObject(
|
||||||
|
job,
|
||||||
|
windows.JobObjectExtendedLimitInformation,
|
||||||
|
uintptr(unsafe.Pointer(&info)),
|
||||||
|
uint32(unsafe.Sizeof(info)),
|
||||||
|
); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||||
|
if err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||||
|
_ = windows.CloseHandle(job)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.job = job
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateGroup 在 Windows 无可靠 SIGTERM 等价物,直接关闭 Job 句柄触发
|
||||||
|
// KILL_ON_JOB_CLOSE 整树终止(grace/done 不参与,仅保持接口签名一致)。
|
||||||
|
func (g *windowsGroup) TerminateGroup(_ <-chan struct{}, _ time.Duration) {
|
||||||
|
g.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *windowsGroup) Close() {
|
||||||
|
if g.job != 0 {
|
||||||
|
_ = windows.CloseHandle(g.job)
|
||||||
|
g.job = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Package run 实现 workspace run profiles:每个 workspace 下可配置多个运行档
|
||||||
|
// (dev/prod/test),后端把每个 profile 作为长驻进程在 workspace 主工作区
|
||||||
|
// (main_path)拉起并托管其完整生命周期(start/stop/restart + 实时日志)。
|
||||||
|
//
|
||||||
|
// 运行态(running/stopped/crashed)只在 RunSupervisor 内存中维护,不落库;
|
||||||
|
// 仅最近一次运行的 last_started_at/last_exit_code/last_error 落行供历史回溯。
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunStatus 是 profile 的内存运行态(不持久化)。
|
||||||
|
type RunStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusStopped RunStatus = "stopped"
|
||||||
|
StatusRunning RunStatus = "running"
|
||||||
|
StatusCrashed RunStatus = "crashed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunProfile 是一个 workspace 下的运行档(持久化实体)。
|
||||||
|
type RunProfile struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
WorkspaceID uuid.UUID
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Command string
|
||||||
|
Args []string
|
||||||
|
EncryptedEnv []byte
|
||||||
|
Enabled bool
|
||||||
|
LastStartedAt *time.Time
|
||||||
|
LastExitCode *int32
|
||||||
|
LastError string
|
||||||
|
CreatedBy uuid.UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 请求 =====
|
||||||
|
|
||||||
|
// CreateRunProfileReq 是创建 run profile 的请求体。Env 为明文,服务端加密落库。
|
||||||
|
type CreateRunProfileReq struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
|
Enabled *bool `json:"enabled"` // 省略默认为 true
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRunProfileReq 是部分更新请求:nil 字段表示不修改。
|
||||||
|
type UpdateRunProfileReq struct {
|
||||||
|
Slug *string `json:"slug"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Command *string `json:"command"`
|
||||||
|
Args *[]string `json:"args"`
|
||||||
|
Env *map[string]string `json:"env"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 响应 =====
|
||||||
|
|
||||||
|
// RunProfileResp 是 profile 的对外表示。env 不回传明文,仅回 env_keys。
|
||||||
|
type RunProfileResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
WorkspaceID string `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EnvKeys []string `json:"env_keys"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt *string `json:"started_at"`
|
||||||
|
LastStartedAt *string `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunStatusResp 是 start/stop/restart/status 的轻量返回。
|
||||||
|
type RunStatusResp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt *string `json:"started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogLineResp 是日志 tail 的返回行。
|
||||||
|
type LogLineResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
TS string `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 转换 helper =====
|
||||||
|
|
||||||
|
func fmtTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
|
||||||
|
|
||||||
|
func fmtTimePtr(t *time.Time) *string {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := t.UTC().Format(time.RFC3339)
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunProfileResp(p *RunProfile, envKeys []string, status RunStatus, startedAt *time.Time) RunProfileResp {
|
||||||
|
args := p.Args
|
||||||
|
if args == nil {
|
||||||
|
args = []string{}
|
||||||
|
}
|
||||||
|
if envKeys == nil {
|
||||||
|
envKeys = []string{}
|
||||||
|
}
|
||||||
|
return RunProfileResp{
|
||||||
|
ID: p.ID.String(),
|
||||||
|
WorkspaceID: p.WorkspaceID.String(),
|
||||||
|
Slug: p.Slug,
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Command: p.Command,
|
||||||
|
Args: args,
|
||||||
|
EnvKeys: envKeys,
|
||||||
|
Enabled: p.Enabled,
|
||||||
|
Status: string(status),
|
||||||
|
StartedAt: fmtTimePtr(startedAt),
|
||||||
|
LastStartedAt: fmtTimePtr(p.LastStartedAt),
|
||||||
|
LastExitCode: p.LastExitCode,
|
||||||
|
LastError: p.LastError,
|
||||||
|
CreatedAt: fmtTime(p.CreatedAt),
|
||||||
|
UpdatedAt: fmtTime(p.UpdatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLogLineResp(e *LogEnvelope) LogLineResp {
|
||||||
|
return LogLineResp{ID: e.ID, Level: e.Level, Text: e.Text, TS: e.TS.UTC().Format(time.RFC3339)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
ws "github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultLogsLimit = 500
|
||||||
|
|
||||||
|
// AdminLookup resolves whether a user is an admin(mirrors acp/workspace pattern)。
|
||||||
|
type AdminLookup interface {
|
||||||
|
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSConfig controls WebSocket heartbeat and buffering for the log stream.
|
||||||
|
type WSConfig struct {
|
||||||
|
PingInterval time.Duration
|
||||||
|
PongTimeout time.Duration
|
||||||
|
SendBuffer int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler 暴露 run profiles 的 HTTP/WS 端点。
|
||||||
|
type Handler struct {
|
||||||
|
svc *Service
|
||||||
|
resolver middleware.SessionResolver
|
||||||
|
adminLookup AdminLookup
|
||||||
|
cfg WSConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler 构造 run.Handler。
|
||||||
|
func NewHandler(svc *Service, resolver middleware.SessionResolver, al AdminLookup, cfg WSConfig) *Handler {
|
||||||
|
return &Handler{svc: svc, resolver: resolver, adminLookup: al, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount 注册 run profiles 路由。分两组:workspace 作用域(列表/创建)与
|
||||||
|
// id 作用域(按 id 操作,内部经 profile→workspace 解析做 ACL)。
|
||||||
|
func (h *Handler) Mount(r chi.Router) {
|
||||||
|
r.Route("/api/v1/workspaces/{wsID}/run-profiles", func(r chi.Router) {
|
||||||
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||||
|
r.Get("/", h.list)
|
||||||
|
r.Post("/", h.create)
|
||||||
|
})
|
||||||
|
r.Route("/api/v1/run-profiles", func(r chi.Router) {
|
||||||
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||||
|
r.Patch("/{id}", h.update)
|
||||||
|
r.Delete("/{id}", h.remove)
|
||||||
|
r.Post("/{id}/start", h.start)
|
||||||
|
r.Post("/{id}/stop", h.stop)
|
||||||
|
r.Post("/{id}/restart", h.restart)
|
||||||
|
r.Get("/{id}/status", h.status)
|
||||||
|
r.Get("/{id}/logs", h.logs)
|
||||||
|
r.Get("/{id}/logs/stream", h.logsStream)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) caller(r *http.Request) (workspace.Caller, error) {
|
||||||
|
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
return workspace.Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated")
|
||||||
|
}
|
||||||
|
admin, err := h.adminLookup.IsAdmin(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Caller{}, err
|
||||||
|
}
|
||||||
|
return workspace.Caller{UserID: uid, IsAdmin: admin}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uuidParam(r *http.Request, key string) (uuid.UUID, error) {
|
||||||
|
id, err := uuid.Parse(chi.URLParam(r, key))
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, errs.New(errs.CodeInvalidInput, "bad "+key)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsID, err := uuidParam(r, "wsID")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.List(r.Context(), c, wsID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsID, err := uuidParam(r, "wsID")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req CreateRunProfileReq
|
||||||
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
||||||
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Create(r.Context(), c, wsID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusCreated, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuidParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UpdateRunProfileReq
|
||||||
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
||||||
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Update(r.Context(), c, id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuidParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(r.Context(), c, id); err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) start(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Start) }
|
||||||
|
func (h *Handler) stop(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Stop) }
|
||||||
|
func (h *Handler) restart(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Restart) }
|
||||||
|
func (h *Handler) status(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Status) }
|
||||||
|
|
||||||
|
// lifecycle 复用 start/stop/restart/status 的公共骨架(鉴权 + 解析 id + 调 svc + 写 RunStatusResp)。
|
||||||
|
func (h *Handler) lifecycle(w http.ResponseWriter, r *http.Request,
|
||||||
|
fn func(context.Context, workspace.Caller, uuid.UUID) (RunStatusResp, error)) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuidParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := fn(r.Context(), c, id)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) logs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuidParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since := parseInt64(r.URL.Query().Get("since"), 0)
|
||||||
|
limit := int(parseInt64(r.URL.Query().Get("limit"), defaultLogsLimit))
|
||||||
|
out, err := h.svc.Logs(r.Context(), c, id, since, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logsStream 升级为 WebSocket,推送内存日志(含 since 续传)与实时新行。只读流,
|
||||||
|
// 不接收客户端消息。鉴权经 middleware.Auth(WS 用 ?token= 传 token)。
|
||||||
|
func (h *Handler) logsStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := h.caller(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuidParam(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since := parseInt64(r.URL.Query().Get("since"), 0)
|
||||||
|
|
||||||
|
sub, relay, running, err := h.svc.SubscribeLogs(r.Context(), c, id, c.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, aerr := ws.Accept(w, r, &ws.AcceptOptions{})
|
||||||
|
if aerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close(ws.StatusInternalError, "internal")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 初始 state 帧。
|
||||||
|
status := StatusStopped
|
||||||
|
if running {
|
||||||
|
status = StatusRunning
|
||||||
|
}
|
||||||
|
if werr := wsjson.Write(ctx, conn, LogEnvelope{Kind: "state", Status: string(status), TS: time.Now()}); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// profile 未运行:无 live relay,推完即关。
|
||||||
|
if !running || relay == nil {
|
||||||
|
conn.Close(ws.StatusNormalClosure, "not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer relay.Unsubscribe(sub.ID)
|
||||||
|
|
||||||
|
// 推送历史缓冲(ID > since)。
|
||||||
|
lastSent := since
|
||||||
|
for _, e := range relay.since(since, 0) {
|
||||||
|
if werr := wsjson.Write(ctx, conn, e); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSent = e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳。
|
||||||
|
pingTicker := time.NewTicker(h.cfg.PingInterval)
|
||||||
|
defer pingTicker.Stop()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-pingTicker.C:
|
||||||
|
pingCtx, pcancel := context.WithTimeout(ctx, h.cfg.PongTimeout)
|
||||||
|
perr := conn.Ping(pingCtx)
|
||||||
|
pcancel()
|
||||||
|
if perr != nil {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case env, ok := <-sub.Send:
|
||||||
|
if !ok {
|
||||||
|
conn.Close(ws.StatusNormalClosure, "relay closed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 历史已推到 lastSent,避免重复推送同 ID 的日志行(控制帧 ID=0 放行)。
|
||||||
|
if env.Kind == "log" && env.ID <= lastSent && env.ID != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if werr := wsjson.Write(ctx, conn, env); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if env.ID > lastSent {
|
||||||
|
lastSent = env.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(s string, def int64) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- name: CreateRunProfile :one
|
||||||
|
INSERT INTO workspace_run_profiles (
|
||||||
|
id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, created_by
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetRunProfileByID :one
|
||||||
|
SELECT * FROM workspace_run_profiles WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListRunProfilesByWorkspace :many
|
||||||
|
SELECT * FROM workspace_run_profiles
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at;
|
||||||
|
|
||||||
|
-- name: UpdateRunProfile :one
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET slug = $2,
|
||||||
|
name = $3,
|
||||||
|
description = $4,
|
||||||
|
command = $5,
|
||||||
|
args = $6,
|
||||||
|
encrypted_env = $7,
|
||||||
|
enabled = $8,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteRunProfile :exec
|
||||||
|
DELETE FROM workspace_run_profiles WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkRunProfileStarted :one
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET last_started_at = now(),
|
||||||
|
last_exit_code = NULL,
|
||||||
|
last_error = '',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateRunProfileExit :exec
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET last_exit_code = $2,
|
||||||
|
last_error = $3,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEnvelope 是推到 WS / 经日志 tail 返回的一行日志或一个控制帧。
|
||||||
|
// - kind="log":普通日志行(level=info 来自 stdout,level=error 来自 stderr)
|
||||||
|
// - kind="state":运行态变化(Status/ExitCode 有意义)
|
||||||
|
// - kind="slow_consumer_disconnect":慢消费者被动断开的控制帧
|
||||||
|
type LogEnvelope struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Level string `json:"level,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
TS time.Time `json:"ts"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ExitCode *int32 `json:"exit_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriber 是单个 WS 连接的订阅句柄。
|
||||||
|
type Subscriber struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
UserID uuid.UUID
|
||||||
|
Send chan *LogEnvelope
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// outRing 是固定行数的环形缓冲:满则淘汰最旧行。承载某次运行的合并 stdout+stderr
|
||||||
|
// 日志,既供 logs(tail/since) 查询,也供 onExit 取尾部写 last_error。
|
||||||
|
type outRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lines []*LogEnvelope
|
||||||
|
cap int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOutRing(capacity int) *outRing {
|
||||||
|
if capacity <= 0 {
|
||||||
|
capacity = 1000
|
||||||
|
}
|
||||||
|
return &outRing{cap: capacity}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *outRing) append(e *LogEnvelope) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.lines) >= r.cap {
|
||||||
|
r.lines = r.lines[1:]
|
||||||
|
}
|
||||||
|
r.lines = append(r.lines, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// since 返回 ID > sinceID 的日志行(最多 limit 条;limit<=0 表示不限)。
|
||||||
|
func (r *outRing) since(sinceID int64, limit int) []*LogEnvelope {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*LogEnvelope, 0, len(r.lines))
|
||||||
|
for _, e := range r.lines {
|
||||||
|
if e.ID > sinceID {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit > 0 && len(out) > limit {
|
||||||
|
out = out[len(out)-limit:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailText 反向重建尾部文本(不超过 maxBytes),供 onExit 写 last_error。
|
||||||
|
func (r *outRing) tailText(maxBytes int) string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := ""
|
||||||
|
for i := len(r.lines) - 1; i >= 0 && len(out) < maxBytes; i-- {
|
||||||
|
out = r.lines[i].Text + "\n" + out
|
||||||
|
}
|
||||||
|
if len(out) > maxBytes {
|
||||||
|
out = out[len(out)-maxBytes:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRelay 是单次运行(一个 RunProc)的日志中继:合并 stdout+stderr 进环形
|
||||||
|
// 缓冲并向所有 WS 订阅者 fanout。结构参照 acp/relay.go,但只做单向日志广播,
|
||||||
|
// 无 JSON-RPC 双向中继。
|
||||||
|
type runRelay struct {
|
||||||
|
profileID uuid.UUID
|
||||||
|
ring *outRing
|
||||||
|
nextID atomic.Int64
|
||||||
|
|
||||||
|
subsMu sync.Mutex
|
||||||
|
subs map[uuid.UUID]*Subscriber
|
||||||
|
sendBuf int
|
||||||
|
|
||||||
|
closed atomic.Bool
|
||||||
|
done chan struct{}
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunRelay(profileID uuid.UUID, bufLines, sendBuf int, log *slog.Logger) *runRelay {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
if sendBuf <= 0 {
|
||||||
|
sendBuf = 256
|
||||||
|
}
|
||||||
|
return &runRelay{
|
||||||
|
profileID: profileID,
|
||||||
|
ring: newOutRing(bufLines),
|
||||||
|
subs: map[uuid.UUID]*Subscriber{},
|
||||||
|
sendBuf: sendBuf,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit 记录一行日志(落环形缓冲 + fanout)。drainPipe goroutine 调用。
|
||||||
|
func (r *runRelay) emit(level, text string) {
|
||||||
|
env := &LogEnvelope{
|
||||||
|
ID: r.nextID.Add(1),
|
||||||
|
Kind: "log",
|
||||||
|
Level: level,
|
||||||
|
Text: text,
|
||||||
|
TS: time.Now(),
|
||||||
|
}
|
||||||
|
r.ring.append(env)
|
||||||
|
r.fanout(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe 注册一个 WS 订阅。
|
||||||
|
func (r *runRelay) Subscribe(userID uuid.UUID) *Subscriber {
|
||||||
|
sub := &Subscriber{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: userID,
|
||||||
|
Send: make(chan *LogEnvelope, r.sendBuf),
|
||||||
|
}
|
||||||
|
r.subsMu.Lock()
|
||||||
|
r.subs[sub.ID] = sub
|
||||||
|
r.subsMu.Unlock()
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe 移除订阅;幂等。
|
||||||
|
func (r *runRelay) Unsubscribe(subID uuid.UUID) {
|
||||||
|
r.subsMu.Lock()
|
||||||
|
defer r.subsMu.Unlock()
|
||||||
|
if sub, ok := r.subs[subID]; ok {
|
||||||
|
if sub.closed.CompareAndSwap(false, true) {
|
||||||
|
close(sub.Send)
|
||||||
|
}
|
||||||
|
delete(r.subs, subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fanout 把一条 envelope 推给所有订阅者;慢消费者(Send 满)自动断开,
|
||||||
|
// 不阻塞其他订阅者(同 acp relay 约定)。
|
||||||
|
func (r *runRelay) fanout(env *LogEnvelope) {
|
||||||
|
r.subsMu.Lock()
|
||||||
|
defer r.subsMu.Unlock()
|
||||||
|
for id, sub := range r.subs {
|
||||||
|
select {
|
||||||
|
case sub.Send <- env:
|
||||||
|
default:
|
||||||
|
if sub.closed.CompareAndSwap(false, true) {
|
||||||
|
select {
|
||||||
|
case sub.Send <- &LogEnvelope{Kind: "slow_consumer_disconnect", TS: time.Now()}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
close(sub.Send)
|
||||||
|
delete(r.subs, id)
|
||||||
|
r.log.Warn("run.relay.slow_subscriber_dropped",
|
||||||
|
"profile_id", r.profileID, "sub_id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// since 返回 ID > sinceID 的历史日志行(供 logs tail / WS 续传)。
|
||||||
|
func (r *runRelay) since(sinceID int64, limit int) []*LogEnvelope {
|
||||||
|
return r.ring.since(sinceID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailText 返回尾部文本(供 onExit 写 last_error)。
|
||||||
|
func (r *runRelay) tailText(maxBytes int) string {
|
||||||
|
return r.ring.tailText(maxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 广播终止 state 帧后关闭所有订阅。幂等。
|
||||||
|
func (r *runRelay) Close(status string, exitCode *int32) {
|
||||||
|
if !r.closed.CompareAndSwap(false, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.fanout(&LogEnvelope{
|
||||||
|
ID: r.nextID.Add(1),
|
||||||
|
Kind: "state",
|
||||||
|
Status: status,
|
||||||
|
ExitCode: exitCode,
|
||||||
|
TS: time.Now(),
|
||||||
|
})
|
||||||
|
r.subsMu.Lock()
|
||||||
|
for id, sub := range r.subs {
|
||||||
|
if sub.closed.CompareAndSwap(false, true) {
|
||||||
|
close(sub.Send)
|
||||||
|
}
|
||||||
|
delete(r.subs, id)
|
||||||
|
}
|
||||||
|
r.subsMu.Unlock()
|
||||||
|
close(r.done)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
runsqlc "github.com/yan1h/agent-coding-workflow/internal/run/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository 是 run 模块对 PG 的全部依赖。Service / Supervisor 单测通过手写
|
||||||
|
// fakeRepo 满足。
|
||||||
|
type Repository interface {
|
||||||
|
CreateRunProfile(ctx context.Context, p *RunProfile) (*RunProfile, error)
|
||||||
|
GetRunProfileByID(ctx context.Context, id uuid.UUID) (*RunProfile, error)
|
||||||
|
ListRunProfilesByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*RunProfile, error)
|
||||||
|
UpdateRunProfile(ctx context.Context, p *RunProfile) (*RunProfile, error)
|
||||||
|
DeleteRunProfile(ctx context.Context, id uuid.UUID) error
|
||||||
|
MarkRunProfileStarted(ctx context.Context, id uuid.UUID) (*RunProfile, error)
|
||||||
|
UpdateRunProfileExit(ctx context.Context, id uuid.UUID, exitCode *int32, lastErr string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUniqueViolation reports whether err is a PG unique constraint violation
|
||||||
|
// (service 层据此把 (workspace_id, slug) 冲突翻成 409)。
|
||||||
|
func IsUniqueViolation(err error) bool {
|
||||||
|
var pg *pgconn.PgError
|
||||||
|
return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation
|
||||||
|
}
|
||||||
|
|
||||||
|
type pgRepo struct {
|
||||||
|
q *runsqlc.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresRepository 用 pgxpool 构造 Repository。
|
||||||
|
func NewPostgresRepository(pool *pgxpool.Pool) Repository {
|
||||||
|
return &pgRepo{q: runsqlc.New(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 类型转换 helper =====
|
||||||
|
|
||||||
|
func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} }
|
||||||
|
|
||||||
|
func fromPgUUID(p pgtype.UUID) uuid.UUID {
|
||||||
|
if !p.Valid {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
return uuid.UUID(p.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromPgTimePtr(t pgtype.Timestamptz) *time.Time {
|
||||||
|
if !t.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := t.Time
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeStrSlice 把 nil 归一为非 nil 空切片,避免 pgx 把 nil []string 写成
|
||||||
|
// SQL NULL 违反 TEXT[] NOT NULL 约束(同 acp/repository.go 的同名 helper)。
|
||||||
|
func normalizeStrSlice(s []string) []string {
|
||||||
|
if s == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowToRunProfile(row runsqlc.WorkspaceRunProfile) *RunProfile {
|
||||||
|
return &RunProfile{
|
||||||
|
ID: fromPgUUID(row.ID),
|
||||||
|
WorkspaceID: fromPgUUID(row.WorkspaceID),
|
||||||
|
Slug: row.Slug,
|
||||||
|
Name: row.Name,
|
||||||
|
Description: row.Description,
|
||||||
|
Command: row.Command,
|
||||||
|
Args: row.Args,
|
||||||
|
EncryptedEnv: row.EncryptedEnv,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
LastStartedAt: fromPgTimePtr(row.LastStartedAt),
|
||||||
|
LastExitCode: row.LastExitCode,
|
||||||
|
LastError: row.LastError,
|
||||||
|
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) CreateRunProfile(ctx context.Context, p *RunProfile) (*RunProfile, error) {
|
||||||
|
row, err := r.q.CreateRunProfile(ctx, runsqlc.CreateRunProfileParams{
|
||||||
|
ID: toPgUUID(p.ID),
|
||||||
|
WorkspaceID: toPgUUID(p.WorkspaceID),
|
||||||
|
Slug: p.Slug,
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Command: p.Command,
|
||||||
|
Args: normalizeStrSlice(p.Args),
|
||||||
|
EncryptedEnv: p.EncryptedEnv,
|
||||||
|
Enabled: p.Enabled,
|
||||||
|
CreatedBy: toPgUUID(p.CreatedBy),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if IsUniqueViolation(err) {
|
||||||
|
return nil, errs.New(errs.CodeRunProfileSlugTaken, "run profile slug already taken in this workspace")
|
||||||
|
}
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "create run profile")
|
||||||
|
}
|
||||||
|
return rowToRunProfile(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) GetRunProfileByID(ctx context.Context, id uuid.UUID) (*RunProfile, error) {
|
||||||
|
row, err := r.q.GetRunProfileByID(ctx, toPgUUID(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errs.New(errs.CodeRunProfileNotFound, "run profile not found")
|
||||||
|
}
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "get run profile")
|
||||||
|
}
|
||||||
|
return rowToRunProfile(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) ListRunProfilesByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*RunProfile, error) {
|
||||||
|
rows, err := r.q.ListRunProfilesByWorkspace(ctx, toPgUUID(wsID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "list run profiles")
|
||||||
|
}
|
||||||
|
out := make([]*RunProfile, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, rowToRunProfile(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) UpdateRunProfile(ctx context.Context, p *RunProfile) (*RunProfile, error) {
|
||||||
|
row, err := r.q.UpdateRunProfile(ctx, runsqlc.UpdateRunProfileParams{
|
||||||
|
ID: toPgUUID(p.ID),
|
||||||
|
Slug: p.Slug,
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Command: p.Command,
|
||||||
|
Args: normalizeStrSlice(p.Args),
|
||||||
|
EncryptedEnv: p.EncryptedEnv,
|
||||||
|
Enabled: p.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errs.New(errs.CodeRunProfileNotFound, "run profile not found")
|
||||||
|
}
|
||||||
|
if IsUniqueViolation(err) {
|
||||||
|
return nil, errs.New(errs.CodeRunProfileSlugTaken, "run profile slug already taken in this workspace")
|
||||||
|
}
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "update run profile")
|
||||||
|
}
|
||||||
|
return rowToRunProfile(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) DeleteRunProfile(ctx context.Context, id uuid.UUID) error {
|
||||||
|
if err := r.q.DeleteRunProfile(ctx, toPgUUID(id)); err != nil {
|
||||||
|
return errs.Wrap(err, errs.CodeInternal, "delete run profile")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) MarkRunProfileStarted(ctx context.Context, id uuid.UUID) (*RunProfile, error) {
|
||||||
|
row, err := r.q.MarkRunProfileStarted(ctx, toPgUUID(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errs.New(errs.CodeRunProfileNotFound, "run profile not found")
|
||||||
|
}
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "mark run profile started")
|
||||||
|
}
|
||||||
|
return rowToRunProfile(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) UpdateRunProfileExit(ctx context.Context, id uuid.UUID, exitCode *int32, lastErr string) error {
|
||||||
|
if err := r.q.UpdateRunProfileExit(ctx, runsqlc.UpdateRunProfileExitParams{
|
||||||
|
ID: toPgUUID(id),
|
||||||
|
LastExitCode: exitCode,
|
||||||
|
LastError: lastErr,
|
||||||
|
}); err != nil {
|
||||||
|
return errs.Wrap(err, errs.CodeInternal, "update run profile exit")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceConfig 是 Service 的运行参数子集(派生自 config.RunConfig)。
|
||||||
|
type ServiceConfig struct {
|
||||||
|
EnvWhitelist []string
|
||||||
|
KillGrace time.Duration
|
||||||
|
OutTailBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 编排 run profile 的 CRUD 与进程生命周期(start/stop/restart/status/logs)。
|
||||||
|
// 鉴权完全继承所属 workspace:读靠 visibility,写靠 owner+admin(经 ProjectAccess)。
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
wsRepo workspace.Repository
|
||||||
|
sup *RunSupervisor
|
||||||
|
pa workspace.ProjectAccess
|
||||||
|
rec audit.Recorder
|
||||||
|
enc *crypto.Encryptor
|
||||||
|
cfg ServiceConfig
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 构造 run.Service。注入 workspace.Repository 以解析 main_path/project_id,
|
||||||
|
// ProjectAccess 做 owner/admin 写鉴权(复用 workspace 的窄接口)。
|
||||||
|
func NewService(
|
||||||
|
repo Repository,
|
||||||
|
wsRepo workspace.Repository,
|
||||||
|
sup *RunSupervisor,
|
||||||
|
pa workspace.ProjectAccess,
|
||||||
|
rec audit.Recorder,
|
||||||
|
enc *crypto.Encryptor,
|
||||||
|
cfg ServiceConfig,
|
||||||
|
log *slog.Logger,
|
||||||
|
) *Service {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
return &Service{repo: repo, wsRepo: wsRepo, sup: sup, pa: pa, rec: rec, enc: enc, cfg: cfg, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 鉴权 helper =====
|
||||||
|
|
||||||
|
// authByWorkspace 解析 workspace 并校验调用方读/写权限。write=true 要求 owner+admin。
|
||||||
|
func (s *Service) authByWorkspace(ctx context.Context, c workspace.Caller, wsID uuid.UUID, write bool) (*workspace.Workspace, error) {
|
||||||
|
ws, err := s.wsRepo.GetWorkspaceByID(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
canRead, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, ws.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if write {
|
||||||
|
if !canWrite {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "no write access to this workspace")
|
||||||
|
}
|
||||||
|
} else if !canRead {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "no read access to this workspace")
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authByProfile 取 profile → 其 workspace,并校验权限。返回 profile 与 workspace。
|
||||||
|
func (s *Service) authByProfile(ctx context.Context, c workspace.Caller, profileID uuid.UUID, write bool) (*RunProfile, *workspace.Workspace, error) {
|
||||||
|
p, err := s.repo.GetRunProfileByID(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ws, err := s.authByWorkspace(ctx, c, p.WorkspaceID, write)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return p, ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CRUD =====
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, c workspace.Caller, wsID uuid.UUID) ([]RunProfileResp, error) {
|
||||||
|
if _, err := s.authByWorkspace(ctx, c, wsID, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles, err := s.repo.ListRunProfilesByWorkspace(ctx, wsID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]RunProfileResp, 0, len(profiles))
|
||||||
|
for _, p := range profiles {
|
||||||
|
out = append(out, s.toResp(p))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, c workspace.Caller, wsID uuid.UUID, req CreateRunProfileReq) (RunProfileResp, error) {
|
||||||
|
if _, err := s.authByWorkspace(ctx, c, wsID, true); err != nil {
|
||||||
|
return RunProfileResp{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Command) == "" {
|
||||||
|
return RunProfileResp{}, errs.New(errs.CodeRunCommandInvalid, "command must not be empty")
|
||||||
|
}
|
||||||
|
encEnv, err := s.encryptEnv(req.Env)
|
||||||
|
if err != nil {
|
||||||
|
return RunProfileResp{}, err
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if req.Enabled != nil {
|
||||||
|
enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
p := &RunProfile{
|
||||||
|
ID: uuid.New(),
|
||||||
|
WorkspaceID: wsID,
|
||||||
|
Slug: req.Slug,
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Command: req.Command,
|
||||||
|
Args: req.Args,
|
||||||
|
EncryptedEnv: encEnv,
|
||||||
|
Enabled: enabled,
|
||||||
|
CreatedBy: c.UserID,
|
||||||
|
}
|
||||||
|
created, err := s.repo.CreateRunProfile(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return RunProfileResp{}, err
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.created", created.ID, map[string]any{"workspace_id": wsID.String(), "slug": created.Slug})
|
||||||
|
return s.toResp(created), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, c workspace.Caller, profileID uuid.UUID, req UpdateRunProfileReq) (RunProfileResp, error) {
|
||||||
|
p, _, err := s.authByProfile(ctx, c, profileID, true)
|
||||||
|
if err != nil {
|
||||||
|
return RunProfileResp{}, err
|
||||||
|
}
|
||||||
|
if req.Slug != nil {
|
||||||
|
p.Slug = *req.Slug
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
p.Name = *req.Name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
p.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Command != nil {
|
||||||
|
if strings.TrimSpace(*req.Command) == "" {
|
||||||
|
return RunProfileResp{}, errs.New(errs.CodeRunCommandInvalid, "command must not be empty")
|
||||||
|
}
|
||||||
|
p.Command = *req.Command
|
||||||
|
}
|
||||||
|
if req.Args != nil {
|
||||||
|
p.Args = *req.Args
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
p.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.Env != nil {
|
||||||
|
encEnv, encErr := s.encryptEnv(*req.Env)
|
||||||
|
if encErr != nil {
|
||||||
|
return RunProfileResp{}, encErr
|
||||||
|
}
|
||||||
|
p.EncryptedEnv = encEnv
|
||||||
|
}
|
||||||
|
updated, err := s.repo.UpdateRunProfile(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return RunProfileResp{}, err
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.updated", updated.ID, map[string]any{"workspace_id": updated.WorkspaceID.String()})
|
||||||
|
return s.toResp(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, c workspace.Caller, profileID uuid.UUID) error {
|
||||||
|
p, _, err := s.authByProfile(ctx, c, profileID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 运行中先整树停掉,避免删行后留下无主进程。
|
||||||
|
if _, running := s.sup.Get(profileID); running {
|
||||||
|
_ = s.sup.Stop(ctx, profileID, s.cfg.KillGrace)
|
||||||
|
}
|
||||||
|
if err := s.repo.DeleteRunProfile(ctx, profileID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.deleted", profileID, map[string]any{"workspace_id": p.WorkspaceID.String()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 进程生命周期 =====
|
||||||
|
|
||||||
|
func (s *Service) Start(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) {
|
||||||
|
p, ws, err := s.authByProfile(ctx, c, profileID, true)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
if !p.Enabled {
|
||||||
|
return RunStatusResp{}, errs.New(errs.CodeRunProfileDisabled, "run profile is disabled")
|
||||||
|
}
|
||||||
|
overrides, err := s.decryptEnv(p.EncryptedEnv)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
_, err = s.sup.Spawn(SpawnParams{
|
||||||
|
ProfileID: p.ID,
|
||||||
|
Command: p.Command,
|
||||||
|
Args: p.Args,
|
||||||
|
Env: s.buildEnv(overrides),
|
||||||
|
Dir: ws.MainPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrAlreadyRunning) {
|
||||||
|
return RunStatusResp{}, errs.New(errs.CodeRunProfileAlreadyRunning, "run profile already running")
|
||||||
|
}
|
||||||
|
return RunStatusResp{}, errs.Wrap(err, errs.CodeRunSpawnFailed, "spawn run profile")
|
||||||
|
}
|
||||||
|
if _, err := s.repo.MarkRunProfileStarted(ctx, p.ID); err != nil {
|
||||||
|
s.log.Error("run.start.mark_started", "profile_id", p.ID, "err", err.Error())
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.started", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()})
|
||||||
|
return s.statusResp(p.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Stop(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) {
|
||||||
|
p, _, err := s.authByProfile(ctx, c, profileID, true)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
if err := s.sup.Stop(ctx, p.ID, s.cfg.KillGrace); err != nil {
|
||||||
|
if errors.Is(err, ErrNotRunning) {
|
||||||
|
return RunStatusResp{}, errs.New(errs.CodeRunProfileNotRunning, "run profile is not running")
|
||||||
|
}
|
||||||
|
return RunStatusResp{}, errs.Wrap(err, errs.CodeInternal, "stop run profile")
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.stopped", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()})
|
||||||
|
return s.statusResp(p.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Restart(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) {
|
||||||
|
p, ws, err := s.authByProfile(ctx, c, profileID, true)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
if !p.Enabled {
|
||||||
|
return RunStatusResp{}, errs.New(errs.CodeRunProfileDisabled, "run profile is disabled")
|
||||||
|
}
|
||||||
|
if _, running := s.sup.Get(p.ID); running {
|
||||||
|
_ = s.sup.Stop(ctx, p.ID, s.cfg.KillGrace)
|
||||||
|
}
|
||||||
|
overrides, err := s.decryptEnv(p.EncryptedEnv)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.sup.Spawn(SpawnParams{
|
||||||
|
ProfileID: p.ID,
|
||||||
|
Command: p.Command,
|
||||||
|
Args: p.Args,
|
||||||
|
Env: s.buildEnv(overrides),
|
||||||
|
Dir: ws.MainPath,
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, ErrAlreadyRunning) {
|
||||||
|
return RunStatusResp{}, errs.New(errs.CodeRunProfileAlreadyRunning, "run profile already running")
|
||||||
|
}
|
||||||
|
return RunStatusResp{}, errs.Wrap(err, errs.CodeRunSpawnFailed, "spawn run profile")
|
||||||
|
}
|
||||||
|
if _, err := s.repo.MarkRunProfileStarted(ctx, p.ID); err != nil {
|
||||||
|
s.log.Error("run.restart.mark_started", "profile_id", p.ID, "err", err.Error())
|
||||||
|
}
|
||||||
|
s.audit(ctx, c, "run.profile.restarted", p.ID, map[string]any{"workspace_id": p.WorkspaceID.String()})
|
||||||
|
return s.statusResp(p.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Status(ctx context.Context, c workspace.Caller, profileID uuid.UUID) (RunStatusResp, error) {
|
||||||
|
p, _, err := s.authByProfile(ctx, c, profileID, false)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{}, err
|
||||||
|
}
|
||||||
|
return s.statusRespFrom(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs 返回正在运行进程的内存日志(ID > since,最多 limit 条)。
|
||||||
|
func (s *Service) Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]LogLineResp, error) {
|
||||||
|
if _, _, err := s.authByProfile(ctx, c, profileID, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
envs := s.sup.LogsSince(profileID, since, limit)
|
||||||
|
out := make([]LogLineResp, 0, len(envs))
|
||||||
|
for _, e := range envs {
|
||||||
|
out = append(out, toLogLineResp(e))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeLogs 暴露给 WS handler:为运行中 profile 注册订阅,返回订阅句柄与 relay。
|
||||||
|
// 同时返回鉴权后的 profile(未运行时 relay 为 nil)。
|
||||||
|
func (s *Service) SubscribeLogs(ctx context.Context, c workspace.Caller, profileID, userID uuid.UUID) (*Subscriber, *runRelay, bool, error) {
|
||||||
|
if _, _, err := s.authByProfile(ctx, c, profileID, false); err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
sub, relay, ok := s.sup.Subscribe(profileID, userID)
|
||||||
|
return sub, relay, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 内部 helper =====
|
||||||
|
|
||||||
|
// buildEnv 构造被托管命令的环境:宿主白名单基础变量 + profile 覆盖。
|
||||||
|
// 不继承宿主全部环境,避免泄漏 APP_MASTER_KEY / DB DSN / git 凭据等敏感变量。
|
||||||
|
func (s *Service) buildEnv(overrides map[string]string) []string {
|
||||||
|
base := map[string]string{}
|
||||||
|
for _, k := range s.cfg.EnvWhitelist {
|
||||||
|
if v, ok := os.LookupEnv(k); ok {
|
||||||
|
base[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range overrides {
|
||||||
|
base[k] = v
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(base))
|
||||||
|
for k, v := range base {
|
||||||
|
out = append(out, k+"="+v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) encryptEnv(env map[string]string) ([]byte, error) {
|
||||||
|
if len(env) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "marshal env")
|
||||||
|
}
|
||||||
|
ct, err := s.enc.Encrypt(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "encrypt env")
|
||||||
|
}
|
||||||
|
return ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) decryptEnv(encrypted []byte) (map[string]string, error) {
|
||||||
|
if len(encrypted) == 0 {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
plain, err := s.enc.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt env")
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(plain, &m); err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal env")
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envKeys 解密并返回排序后的 env key 列表(仅 key,供前端展示)。解密失败回空列表。
|
||||||
|
func (s *Service) envKeys(encrypted []byte) []string {
|
||||||
|
m, err := s.decryptEnv(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("run.env_keys.decrypt_failed", "err", err.Error())
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return sortedKeys(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) toResp(p *RunProfile) RunProfileResp {
|
||||||
|
status := s.sup.StatusOf(p.ID)
|
||||||
|
var startedAt *time.Time
|
||||||
|
if proc, ok := s.sup.Get(p.ID); ok {
|
||||||
|
t := proc.StartedAt
|
||||||
|
startedAt = &t
|
||||||
|
}
|
||||||
|
return buildRunProfileResp(p, s.envKeys(p.EncryptedEnv), status, startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) statusResp(profileID uuid.UUID) RunStatusResp {
|
||||||
|
p, err := s.repo.GetRunProfileByID(context.Background(), profileID)
|
||||||
|
if err != nil {
|
||||||
|
return RunStatusResp{Status: string(s.sup.StatusOf(profileID))}
|
||||||
|
}
|
||||||
|
return s.statusRespFrom(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) statusRespFrom(p *RunProfile) RunStatusResp {
|
||||||
|
status := s.sup.StatusOf(p.ID)
|
||||||
|
var startedAt *string
|
||||||
|
if proc, ok := s.sup.Get(p.ID); ok {
|
||||||
|
t := proc.StartedAt.UTC().Format(time.RFC3339)
|
||||||
|
startedAt = &t
|
||||||
|
}
|
||||||
|
return RunStatusResp{
|
||||||
|
Status: string(status),
|
||||||
|
StartedAt: startedAt,
|
||||||
|
LastExitCode: p.LastExitCode,
|
||||||
|
LastError: p.LastError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) audit(ctx context.Context, c workspace.Caller, action string, targetID uuid.UUID, meta map[string]any) {
|
||||||
|
if s.rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := c.UserID
|
||||||
|
_ = s.rec.Record(ctx, audit.Entry{
|
||||||
|
UserID: &uid,
|
||||||
|
Action: action,
|
||||||
|
TargetType: "run_profile",
|
||||||
|
TargetID: targetID.String(),
|
||||||
|
Metadata: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package runsqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
|
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
|
||||||
|
package runsqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AcpAgentKind struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
BinaryPath string `json:"binary_path"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AcpEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SessionID pgtype.UUID `json:"session_id"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
RpcKind string `json:"rpc_kind"`
|
||||||
|
Method *string `json:"method"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
PayloadSize int32 `json:"payload_size"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AcpPermissionRequest struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
SessionID pgtype.UUID `json:"session_id"`
|
||||||
|
AgentRequestID string `json:"agent_request_id"`
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
ToolCall []byte `json:"tool_call"`
|
||||||
|
Options []byte `json:"options"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ChosenOptionID *string `json:"chosen_option_id"`
|
||||||
|
DecidedBy pgtype.UUID `json:"decided_by"`
|
||||||
|
DecidedAt pgtype.Timestamptz `json:"decided_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AcpSession struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
AgentSessionID *string `json:"agent_session_id"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
CwdPath string `json:"cwd_path"`
|
||||||
|
IsMainWorktree bool `json:"is_main_worktree"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Pid *int32 `json:"pid"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
LastError *string `json:"last_error"`
|
||||||
|
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||||
|
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetType *string `json:"target_type"`
|
||||||
|
TargetID *string `json:"target_id"`
|
||||||
|
Ip *netip.Addr `json:"ip"`
|
||||||
|
Metadata []byte `json:"metadata"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Conversation struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
ModelID pgtype.UUID `json:"model_id"`
|
||||||
|
SystemPrompt string `json:"system_prompt"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitCredential struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Username *string `json:"username"`
|
||||||
|
EncryptedSecret []byte `json:"encrypted_secret"`
|
||||||
|
Fingerprint *string `json:"fingerprint"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Issue struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
|
Number int32 `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||||
|
Attempts int32 `json:"attempts"`
|
||||||
|
MaxAttempts int32 `json:"max_attempts"`
|
||||||
|
LeasedAt pgtype.Timestamptz `json:"leased_at"`
|
||||||
|
LeasedBy *string `json:"leased_by"`
|
||||||
|
LastError *string `json:"last_error"`
|
||||||
|
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LlmEndpoint struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
BaseUrl string `json:"base_url"`
|
||||||
|
ApiKeyEncrypted []byte `json:"api_key_encrypted"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LlmModel struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
EndpointID pgtype.UUID `json:"endpoint_id"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Capabilities []byte `json:"capabilities"`
|
||||||
|
ContextWindow int32 `json:"context_window"`
|
||||||
|
MaxOutputTokens int32 `json:"max_output_tokens"`
|
||||||
|
PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"`
|
||||||
|
CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"`
|
||||||
|
ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"`
|
||||||
|
IsTitleGenerator bool `json:"is_title_generator"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LlmUsage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
EndpointID pgtype.UUID `json:"endpoint_id"`
|
||||||
|
ModelID pgtype.UUID `json:"model_id"`
|
||||||
|
PromptTokens int32 `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int32 `json:"completion_tokens"`
|
||||||
|
ThinkingTokens int32 `json:"thinking_tokens"`
|
||||||
|
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpToken struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
TokenHash []byte `json:"token_hash"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Scope []byte `json:"scope"`
|
||||||
|
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||||
|
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ConversationID pgtype.UUID `json:"conversation_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Thinking *string `json:"thinking"`
|
||||||
|
ToolCalls []byte `json:"tool_calls"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ErrorMessage *string `json:"error_message"`
|
||||||
|
PromptTokens *int32 `json:"prompt_tokens"`
|
||||||
|
CompletionTokens *int32 `json:"completion_tokens"`
|
||||||
|
ThinkingTokens *int32 `json:"thinking_tokens"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageAttachment struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
MessageID *int64 `json:"message_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Sha256 string `json:"sha256"`
|
||||||
|
StoragePath string `json:"storage_path"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Link *string `json:"link"`
|
||||||
|
Metadata []byte `json:"metadata"`
|
||||||
|
ReadAt pgtype.Timestamptz `json:"read_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
OwnerID pgtype.UUID `json:"owner_id"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptTemplate struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
OwnerID pgtype.UUID `json:"owner_id"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Requirement struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Number int32 `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OwnerID pgtype.UUID `json:"owner_id"`
|
||||||
|
ClosedAt pgtype.Timestamptz `json:"closed_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSession struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
|
TokenHash []byte `json:"token_hash"`
|
||||||
|
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||||
|
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Workspace struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
GitRemoteUrl string `json:"git_remote_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
MainPath string `json:"main_path"`
|
||||||
|
SyncStatus string `json:"sync_status"`
|
||||||
|
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
|
||||||
|
LastSyncError *string `json:"last_sync_error"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceWorktree struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ActiveHolder *string `json:"active_holder"`
|
||||||
|
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
|
||||||
|
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: run_profiles.sql
|
||||||
|
|
||||||
|
package runsqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createRunProfile = `-- name: CreateRunProfile :one
|
||||||
|
INSERT INTO workspace_run_profiles (
|
||||||
|
id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, created_by
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateRunProfileParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateRunProfile(ctx context.Context, arg CreateRunProfileParams) (WorkspaceRunProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createRunProfile,
|
||||||
|
arg.ID,
|
||||||
|
arg.WorkspaceID,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.Command,
|
||||||
|
arg.Args,
|
||||||
|
arg.EncryptedEnv,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.CreatedBy,
|
||||||
|
)
|
||||||
|
var i WorkspaceRunProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Command,
|
||||||
|
&i.Args,
|
||||||
|
&i.EncryptedEnv,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastStartedAt,
|
||||||
|
&i.LastExitCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRunProfile = `-- name: DeleteRunProfile :exec
|
||||||
|
DELETE FROM workspace_run_profiles WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteRunProfile(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteRunProfile, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRunProfileByID = `-- name: GetRunProfileByID :one
|
||||||
|
SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at FROM workspace_run_profiles WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetRunProfileByID(ctx context.Context, id pgtype.UUID) (WorkspaceRunProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getRunProfileByID, id)
|
||||||
|
var i WorkspaceRunProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Command,
|
||||||
|
&i.Args,
|
||||||
|
&i.EncryptedEnv,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastStartedAt,
|
||||||
|
&i.LastExitCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listRunProfilesByWorkspace = `-- name: ListRunProfilesByWorkspace :many
|
||||||
|
SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at FROM workspace_run_profiles
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListRunProfilesByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceRunProfile, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listRunProfilesByWorkspace, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []WorkspaceRunProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var i WorkspaceRunProfile
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Command,
|
||||||
|
&i.Args,
|
||||||
|
&i.EncryptedEnv,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastStartedAt,
|
||||||
|
&i.LastExitCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markRunProfileStarted = `-- name: MarkRunProfileStarted :one
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET last_started_at = now(),
|
||||||
|
last_exit_code = NULL,
|
||||||
|
last_error = '',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) MarkRunProfileStarted(ctx context.Context, id pgtype.UUID) (WorkspaceRunProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, markRunProfileStarted, id)
|
||||||
|
var i WorkspaceRunProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Command,
|
||||||
|
&i.Args,
|
||||||
|
&i.EncryptedEnv,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastStartedAt,
|
||||||
|
&i.LastExitCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRunProfile = `-- name: UpdateRunProfile :one
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET slug = $2,
|
||||||
|
name = $3,
|
||||||
|
description = $4,
|
||||||
|
command = $5,
|
||||||
|
args = $6,
|
||||||
|
encrypted_env = $7,
|
||||||
|
enabled = $8,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateRunProfileParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateRunProfile(ctx context.Context, arg UpdateRunProfileParams) (WorkspaceRunProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateRunProfile,
|
||||||
|
arg.ID,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Name,
|
||||||
|
arg.Description,
|
||||||
|
arg.Command,
|
||||||
|
arg.Args,
|
||||||
|
arg.EncryptedEnv,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i WorkspaceRunProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.Description,
|
||||||
|
&i.Command,
|
||||||
|
&i.Args,
|
||||||
|
&i.EncryptedEnv,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastStartedAt,
|
||||||
|
&i.LastExitCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRunProfileExit = `-- name: UpdateRunProfileExit :exec
|
||||||
|
UPDATE workspace_run_profiles
|
||||||
|
SET last_exit_code = $2,
|
||||||
|
last_error = $3,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateRunProfileExitParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateRunProfileExit(ctx context.Context, arg UpdateRunProfileExitParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateRunProfileExit, arg.ID, arg.LastExitCode, arg.LastError)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// supervisor.go 管理 run profile 子进程的内存运行态。每个 profile 同一时刻至多
|
||||||
|
// 一个运行进程。并发骨架照搬 acp/supervisor.go 已被测试验证的不变量:
|
||||||
|
// - procs map 的写锁仅在 Spawn 插入 / monitor 删除时持有
|
||||||
|
// - done channel 严格 close-once(monitorProcess 唯一持有)
|
||||||
|
// - KilledByUs atomic 区分 stopped(我方终止)vs crashed
|
||||||
|
// - onExit 不得重新 Lock s.mu(避免 monitor→Stop→onExit 链式死锁)
|
||||||
|
//
|
||||||
|
// 与 acp 的差异:无 JSON-RPC handshake / Relay 双向中继 / MCP token / worktree
|
||||||
|
// acquire;并补上经 internal/infra/proc 的整树终止(dev server 会派生子孙进程)。
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
procgrp "github.com/yan1h/agent-coding-workflow/internal/infra/proc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupervisorConfig 是从 config.RunConfig 派生的 supervisor 子集。
|
||||||
|
type SupervisorConfig struct {
|
||||||
|
KillGrace time.Duration
|
||||||
|
OutBufferLines int
|
||||||
|
OutTailBytes int
|
||||||
|
WSSendBuffer int
|
||||||
|
ShutdownGrace time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunProc 是单个运行进程的运行时句柄。
|
||||||
|
type RunProc struct {
|
||||||
|
ProfileID uuid.UUID
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
group procgrp.Group
|
||||||
|
relay *runRelay
|
||||||
|
|
||||||
|
StartedAt time.Time
|
||||||
|
KilledByUs atomic.Bool // true → stopped;false → crashed
|
||||||
|
done chan struct{} // monitor 退出时 close
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSupervisor 是 run profile 子进程总管。被动结构:Spawn 时起 goroutine,无主循环。
|
||||||
|
type RunSupervisor struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
procs map[uuid.UUID]*RunProc
|
||||||
|
terminal map[uuid.UUID]RunStatus // 最近一次退出的终止态(crashed/stopped),重启即清空
|
||||||
|
|
||||||
|
repo Repository
|
||||||
|
cfg SupervisorConfig
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSupervisor 构造空 RunSupervisor。
|
||||||
|
func NewSupervisor(repo Repository, cfg SupervisorConfig, log *slog.Logger) *RunSupervisor {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
return &RunSupervisor{
|
||||||
|
procs: map[uuid.UUID]*RunProc{},
|
||||||
|
terminal: map[uuid.UUID]RunStatus{},
|
||||||
|
repo: repo,
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnParams 由 Service 构造:命令、参数、最终环境(宿主白名单+覆盖)、工作目录。
|
||||||
|
type SpawnParams struct {
|
||||||
|
ProfileID uuid.UUID
|
||||||
|
Command string
|
||||||
|
Args []string
|
||||||
|
Env []string
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrAlreadyRunning / ErrNotRunning 是 supervisor 层的哨兵,Service 翻译为 errs code。
|
||||||
|
var (
|
||||||
|
ErrAlreadyRunning = errSentinel("run: profile already running")
|
||||||
|
ErrNotRunning = errSentinel("run: profile not running")
|
||||||
|
)
|
||||||
|
|
||||||
|
type errSentinel string
|
||||||
|
|
||||||
|
func (e errSentinel) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// Spawn 启动一个子进程并托管。调用方(Service)负责前置鉴权与 env 构造。
|
||||||
|
// 同一 profile 已在运行时返回 ErrAlreadyRunning。
|
||||||
|
func (s *RunSupervisor) Spawn(p SpawnParams) (*RunProc, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
_, running := s.procs[p.ProfileID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if running {
|
||||||
|
return nil, ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(p.Command, p.Args...)
|
||||||
|
cmd.Dir = p.Dir
|
||||||
|
cmd.Env = p.Env
|
||||||
|
|
||||||
|
// 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后
|
||||||
|
// (Windows Job Object)。终止时整树清理,避免 dev server 子孙进程残留。
|
||||||
|
group := procgrp.NewGroup()
|
||||||
|
group.Prepare(cmd)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := group.Adopt(cmd); err != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
proc := &RunProc{
|
||||||
|
ProfileID: p.ProfileID,
|
||||||
|
Cmd: cmd,
|
||||||
|
group: group,
|
||||||
|
relay: newRunRelay(p.ProfileID, s.cfg.OutBufferLines, s.cfg.WSSendBuffer, s.log),
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.procs[p.ProfileID] = proc
|
||||||
|
delete(s.terminal, p.ProfileID) // 进入运行态,清除上一次终止态
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go s.drainPipe(proc, stdout, "info")
|
||||||
|
go s.drainPipe(proc, stderr, "error")
|
||||||
|
go s.monitorProcess(proc)
|
||||||
|
|
||||||
|
return proc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 终止指定 profile 的子进程(整树)。幂等阻塞直到 done 关闭或 ctx 取消。
|
||||||
|
// profile 未运行时返回 ErrNotRunning。
|
||||||
|
func (s *RunSupervisor) Stop(ctx context.Context, profileID uuid.UUID, grace time.Duration) error {
|
||||||
|
s.mu.RLock()
|
||||||
|
proc, ok := s.procs[profileID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return ErrNotRunning
|
||||||
|
}
|
||||||
|
proc.KilledByUs.Store(true)
|
||||||
|
proc.group.TerminateGroup(proc.done, grace)
|
||||||
|
select {
|
||||||
|
case <-proc.done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 返回指定 profile 当前运行进程(用于 status/logs/subscribe);未运行返回 false。
|
||||||
|
func (s *RunSupervisor) Get(profileID uuid.UUID) (*RunProc, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
proc, ok := s.procs[profileID]
|
||||||
|
return proc, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusOf 返回内存运行态:在 procs 中 → running;否则取最近终止态(crashed/stopped),
|
||||||
|
// 无记录(含服务端重启后)→ stopped。
|
||||||
|
func (s *RunSupervisor) StatusOf(profileID uuid.UUID) RunStatus {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if _, ok := s.procs[profileID]; ok {
|
||||||
|
return StatusRunning
|
||||||
|
}
|
||||||
|
if st, ok := s.terminal[profileID]; ok {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
return StatusStopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe 为正在运行的 profile 注册一个日志订阅;未运行返回 (nil,false)。
|
||||||
|
func (s *RunSupervisor) Subscribe(profileID, userID uuid.UUID) (*Subscriber, *runRelay, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
proc, ok := s.procs[profileID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return proc.relay.Subscribe(userID), proc.relay, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogsSince 返回正在运行进程的内存日志(ID > since);未运行返回 nil。
|
||||||
|
func (s *RunSupervisor) LogsSince(profileID uuid.UUID, since int64, limit int) []*LogEnvelope {
|
||||||
|
s.mu.RLock()
|
||||||
|
proc, ok := s.procs[profileID]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return proc.relay.since(since, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainPipe 逐行读取管道并 emit 到 relay。EOF/读错误时安静退出(Wait 由 monitor 处理)。
|
||||||
|
func (s *RunSupervisor) drainPipe(proc *RunProc, pipe io.Reader, level string) {
|
||||||
|
scanner := bufio.NewScanner(pipe)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
proc.relay.emit(level, scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorProcess 等待进程退出,更新 procs/terminal map,释放进程树句柄并触发 onExit。
|
||||||
|
// 这是 done channel 唯一的合法关闭路径。
|
||||||
|
func (s *RunSupervisor) monitorProcess(proc *RunProc) {
|
||||||
|
defer close(proc.done)
|
||||||
|
waitErr := proc.Cmd.Wait()
|
||||||
|
|
||||||
|
exitCode := int32(0)
|
||||||
|
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||||
|
exitCode = int32(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
status := StatusStopped
|
||||||
|
if !proc.KilledByUs.Load() {
|
||||||
|
status = StatusCrashed
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.procs, proc.ProfileID)
|
||||||
|
s.terminal[proc.ProfileID] = status
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// 释放进程树句柄(Windows 关闭 Job handle;Unix no-op)。
|
||||||
|
proc.group.Close()
|
||||||
|
|
||||||
|
s.onExit(context.Background(), proc, status, exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// onExit 把退出信息落库并广播终止帧。不得再 Lock s.mu(monitorProcess 已 delete
|
||||||
|
// map 后调用本函数;Stop 也持读锁等 done,重入会死锁)。
|
||||||
|
func (s *RunSupervisor) onExit(ctx context.Context, proc *RunProc, status RunStatus, exitCode int32) {
|
||||||
|
lastErr := ""
|
||||||
|
if status == StatusCrashed {
|
||||||
|
lastErr = proc.relay.tailText(s.cfg.OutTailBytes)
|
||||||
|
}
|
||||||
|
ec := exitCode
|
||||||
|
if s.repo != nil {
|
||||||
|
if err := s.repo.UpdateRunProfileExit(ctx, proc.ProfileID, &ec, lastErr); err != nil {
|
||||||
|
s.log.Error("run.on_exit.update_exit", "profile_id", proc.ProfileID, "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proc.relay.Close(string(status), &ec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShutdownAll 并行终止所有运行进程,受 cfg.ShutdownGrace 总时限约束。
|
||||||
|
func (s *RunSupervisor) ShutdownAll(ctx context.Context) {
|
||||||
|
s.mu.RLock()
|
||||||
|
ids := make([]uuid.UUID, 0, len(s.procs))
|
||||||
|
for id := range s.procs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, s.cfg.ShutdownGrace)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, id := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(pid uuid.UUID) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = s.Stop(shutdownCtx, pid, s.cfg.KillGrace)
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
s.log.Warn("run.shutdown_all.timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRepo 仅实现 supervisor 实际用到的 UpdateRunProfileExit;其余满足接口即可。
|
||||||
|
type fakeRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
exitID uuid.UUID
|
||||||
|
exitCode *int32
|
||||||
|
lastErr string
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) CreateRunProfile(context.Context, *RunProfile) (*RunProfile, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) GetRunProfileByID(context.Context, uuid.UUID) (*RunProfile, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) ListRunProfilesByWorkspace(context.Context, uuid.UUID) ([]*RunProfile, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) UpdateRunProfile(context.Context, *RunProfile) (*RunProfile, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) DeleteRunProfile(context.Context, uuid.UUID) error { return nil }
|
||||||
|
func (f *fakeRepo) MarkRunProfileStarted(context.Context, uuid.UUID) (*RunProfile, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepo) UpdateRunProfileExit(_ context.Context, id uuid.UUID, exitCode *int32, lastErr string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.called = true
|
||||||
|
f.exitID = id
|
||||||
|
f.exitCode = exitCode
|
||||||
|
f.lastErr = lastErr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCfg() SupervisorConfig {
|
||||||
|
return SupervisorConfig{
|
||||||
|
KillGrace: 2 * time.Second,
|
||||||
|
OutBufferLines: 100,
|
||||||
|
OutTailBytes: 2000,
|
||||||
|
WSSendBuffer: 16,
|
||||||
|
ShutdownGrace: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickExit() SpawnParams {
|
||||||
|
p := SpawnParams{ProfileID: uuid.New()}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
p.Command, p.Args = "cmd", []string{"/c", "exit", "0"}
|
||||||
|
} else {
|
||||||
|
p.Command, p.Args = "sh", []string{"-c", "exit 0"}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleeper() SpawnParams {
|
||||||
|
p := SpawnParams{ProfileID: uuid.New()}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
p.Command, p.Args = "cmd", []string{"/c", "timeout", "/t", "30", "/nobreak"}
|
||||||
|
} else {
|
||||||
|
p.Command, p.Args = "sh", []string{"-c", "sleep 30"}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawn_SelfExit_RecordsCrash 验证:未被我方终止而自行退出 → crashed,
|
||||||
|
// 且 onExit 把退出码落库(monitor 在 close(done) 前已完成 onExit)。
|
||||||
|
func TestSpawn_SelfExit_RecordsCrash(t *testing.T) {
|
||||||
|
repo := &fakeRepo{}
|
||||||
|
sup := NewSupervisor(repo, testCfg(), nil)
|
||||||
|
params := quickExit()
|
||||||
|
|
||||||
|
proc, err := sup.Spawn(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-proc.done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("process did not exit")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sup.StatusOf(params.ProfileID); got != StatusCrashed {
|
||||||
|
t.Fatalf("status = %q, want crashed (self-exit not initiated by us)", got)
|
||||||
|
}
|
||||||
|
repo.mu.Lock()
|
||||||
|
defer repo.mu.Unlock()
|
||||||
|
if !repo.called {
|
||||||
|
t.Fatal("UpdateRunProfileExit not called on exit")
|
||||||
|
}
|
||||||
|
if repo.exitID != params.ProfileID {
|
||||||
|
t.Fatalf("exit recorded for wrong profile: %v", repo.exitID)
|
||||||
|
}
|
||||||
|
if repo.exitCode == nil || *repo.exitCode != 0 {
|
||||||
|
t.Fatalf("exit code = %v, want 0", repo.exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStop_TerminatesAndMarksStopped 验证 Stop 整树终止并判定为 stopped。
|
||||||
|
func TestStop_TerminatesAndMarksStopped(t *testing.T) {
|
||||||
|
repo := &fakeRepo{}
|
||||||
|
sup := NewSupervisor(repo, testCfg(), nil)
|
||||||
|
params := sleeper()
|
||||||
|
|
||||||
|
proc, err := sup.Spawn(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spawn: %v", err)
|
||||||
|
}
|
||||||
|
if got := sup.StatusOf(params.ProfileID); got != StatusRunning {
|
||||||
|
t.Fatalf("status = %q, want running", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sup.Stop(context.Background(), params.ProfileID, testCfg().KillGrace); err != nil {
|
||||||
|
t.Fatalf("stop: %v", err)
|
||||||
|
}
|
||||||
|
// Stop 已等 done;onExit 在 close(done) 前完成。
|
||||||
|
select {
|
||||||
|
case <-proc.done:
|
||||||
|
default:
|
||||||
|
t.Fatal("done not closed after Stop")
|
||||||
|
}
|
||||||
|
if got := sup.StatusOf(params.ProfileID); got != StatusStopped {
|
||||||
|
t.Fatalf("status = %q, want stopped", got)
|
||||||
|
}
|
||||||
|
if _, ok := sup.Get(params.ProfileID); ok {
|
||||||
|
t.Fatal("proc still registered after stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpawn_AlreadyRunning 验证同一 profile 不能并发拉起两个进程。
|
||||||
|
func TestSpawn_AlreadyRunning(t *testing.T) {
|
||||||
|
sup := NewSupervisor(&fakeRepo{}, testCfg(), nil)
|
||||||
|
params := sleeper()
|
||||||
|
|
||||||
|
if _, err := sup.Spawn(params); err != nil {
|
||||||
|
t.Fatalf("first spawn: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = sup.Stop(context.Background(), params.ProfileID, testCfg().KillGrace) }()
|
||||||
|
|
||||||
|
if _, err := sup.Spawn(params); err != ErrAlreadyRunning {
|
||||||
|
t.Fatalf("second spawn err = %v, want ErrAlreadyRunning", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStop_NotRunning 验证停止未运行的 profile 返回 ErrNotRunning。
|
||||||
|
func TestStop_NotRunning(t *testing.T) {
|
||||||
|
sup := NewSupervisor(&fakeRepo{}, testCfg(), nil)
|
||||||
|
if err := sup.Stop(context.Background(), uuid.New(), testCfg().KillGrace); err != ErrNotRunning {
|
||||||
|
t.Fatalf("err = %v, want ErrNotRunning", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ type Workspace struct {
|
|||||||
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceRunProfile struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
|
||||||
|
LastExitCode *int32 `json:"last_exit_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceWorktree struct {
|
type WorkspaceWorktree struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS workspace_run_profiles;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============ Workspace Run Profiles ============
|
||||||
|
-- 每个 workspace 下可配置多个运行档(dev/prod/test)。后端把每个 profile
|
||||||
|
-- 作为长驻进程拉起;运行态(running/stopped/crashed)在 RunSupervisor 内存中维护,
|
||||||
|
-- 不落库。下列三个 last_* 字段仅记录最近一次运行的历史,供前端展示与排障。
|
||||||
|
CREATE TABLE workspace_run_profiles (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
command TEXT NOT NULL, -- 启动命令(exec 的 argv[0] 或 shell -c 串)
|
||||||
|
args TEXT[] NOT NULL DEFAULT '{}', -- 额外参数
|
||||||
|
encrypted_env BYTEA, -- AES-GCM 密文(profile env 覆盖,map[string]string 的 JSON)
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_started_at TIMESTAMPTZ, -- 最近一次 start 时间
|
||||||
|
last_exit_code INTEGER, -- 最近一次退出码(NULL=从未退出/从未运行)
|
||||||
|
last_error TEXT NOT NULL DEFAULT '', -- 最近一次 stderr 尾部 / 启动错误
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, slug)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_run_profiles_workspace ON workspace_run_profiles(workspace_id);
|
||||||
@@ -90,3 +90,13 @@ sql:
|
|||||||
sql_package: pgx/v5
|
sql_package: pgx/v5
|
||||||
emit_pointers_for_null_types: true
|
emit_pointers_for_null_types: true
|
||||||
emit_json_tags: true
|
emit_json_tags: true
|
||||||
|
- engine: postgresql
|
||||||
|
queries: internal/run/queries
|
||||||
|
schema: migrations
|
||||||
|
gen:
|
||||||
|
go:
|
||||||
|
package: runsqlc
|
||||||
|
out: internal/run/sqlc
|
||||||
|
sql_package: pgx/v5
|
||||||
|
emit_pointers_for_null_types: true
|
||||||
|
emit_json_tags: true
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { request } from './client'
|
||||||
|
|
||||||
|
export type RunState = 'running' | 'stopped' | 'crashed'
|
||||||
|
|
||||||
|
export interface RunProfile {
|
||||||
|
id: string
|
||||||
|
workspace_id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
command: string
|
||||||
|
args: string[]
|
||||||
|
env_keys: string[]
|
||||||
|
enabled: boolean
|
||||||
|
status: RunState
|
||||||
|
started_at: string | null
|
||||||
|
last_started_at: string | null
|
||||||
|
last_exit_code: number | null
|
||||||
|
last_error: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunStatus {
|
||||||
|
status: RunState
|
||||||
|
started_at: string | null
|
||||||
|
last_exit_code: number | null
|
||||||
|
last_error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogLine {
|
||||||
|
id: number
|
||||||
|
level: 'info' | 'error'
|
||||||
|
text: string
|
||||||
|
ts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRunProfileBody {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
command: string
|
||||||
|
args?: string[]
|
||||||
|
env?: Record<string, string>
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateRunProfileBody = Partial<CreateRunProfileBody>
|
||||||
|
|
||||||
|
const enc = (v: string) => encodeURIComponent(v)
|
||||||
|
|
||||||
|
export const runsApi = {
|
||||||
|
list: (wsID: string) => request<RunProfile[]>(`/api/v1/workspaces/${enc(wsID)}/run-profiles/`),
|
||||||
|
create: (wsID: string, body: CreateRunProfileBody) =>
|
||||||
|
request<RunProfile>(`/api/v1/workspaces/${enc(wsID)}/run-profiles/`, { method: 'POST', body }),
|
||||||
|
update: (id: string, body: UpdateRunProfileBody) =>
|
||||||
|
request<RunProfile>(`/api/v1/run-profiles/${enc(id)}`, { method: 'PATCH', body }),
|
||||||
|
remove: (id: string) => request<void>(`/api/v1/run-profiles/${enc(id)}`, { method: 'DELETE' }),
|
||||||
|
start: (id: string) =>
|
||||||
|
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/start`, { method: 'POST' }),
|
||||||
|
stop: (id: string) =>
|
||||||
|
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/stop`, { method: 'POST' }),
|
||||||
|
restart: (id: string) =>
|
||||||
|
request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/restart`, { method: 'POST' }),
|
||||||
|
status: (id: string) => request<RunStatus>(`/api/v1/run-profiles/${enc(id)}/status`),
|
||||||
|
logs: (id: string, since = 0) =>
|
||||||
|
request<LogLine[]>(`/api/v1/run-profiles/${enc(id)}/logs?since=${since}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRunLogsWS 打开到日志流的原生 WebSocket。浏览器 WS 不支持自定义 header,
|
||||||
|
// 故 token 经 ?token= 传递(同 acp WS 约定)。
|
||||||
|
export function openRunLogsWS(profileID: string, since: number, token: string): WebSocket {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const host = window.location.host
|
||||||
|
const url =
|
||||||
|
`${proto}://${host}/api/v1/run-profiles/${enc(profileID)}` +
|
||||||
|
`/logs/stream?since=${since}&token=${enc(token)}`
|
||||||
|
return new WebSocket(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// useRunStream 封装 run profile 的日志 WebSocket,结构照搬 useAcpStream:
|
||||||
|
// - connect + 自动重连(指数退避 1s/3s,最多 2 次)
|
||||||
|
// - ?since=lastLogID 续传
|
||||||
|
// - 把日志行累积进 ref 数组;state 帧更新 runStatus
|
||||||
|
import { ref, onScopeDispose, type Ref } from 'vue'
|
||||||
|
import { openRunLogsWS, type LogLine, type RunState } from '@/api/runs'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
|
||||||
|
|
||||||
|
export interface UseRunStreamReturn {
|
||||||
|
status: Ref<StreamStatus>
|
||||||
|
runStatus: Ref<RunState>
|
||||||
|
logs: Ref<LogLine[]>
|
||||||
|
reconnect: () => void
|
||||||
|
close: () => void
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireFrame {
|
||||||
|
id?: number
|
||||||
|
kind?: string
|
||||||
|
level?: 'info' | 'error'
|
||||||
|
text?: string
|
||||||
|
ts?: string
|
||||||
|
status?: RunState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunStream(profileId: string): UseRunStreamReturn {
|
||||||
|
const status = ref<StreamStatus>('idle')
|
||||||
|
const runStatus = ref<RunState>('stopped')
|
||||||
|
const logs = ref<LogLine[]>([])
|
||||||
|
const lastLogID = ref(0)
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let retried = 0
|
||||||
|
let manualClose = false
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws || manualClose) return
|
||||||
|
status.value = 'connecting'
|
||||||
|
const token = auth.token ?? ''
|
||||||
|
const sock = openRunLogsWS(profileId, lastLogID.value, token)
|
||||||
|
ws = sock
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
status.value = 'streaming'
|
||||||
|
retried = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.onmessage = (ev: MessageEvent) => {
|
||||||
|
let parsed: WireFrame
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(ev.data as string) as WireFrame
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') return
|
||||||
|
|
||||||
|
if (parsed.kind === 'state') {
|
||||||
|
if (parsed.status) runStatus.value = parsed.status
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsed.kind === 'slow_consumer_disconnect') {
|
||||||
|
status.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 普通日志行
|
||||||
|
if (parsed.kind === 'log' && typeof parsed.id === 'number') {
|
||||||
|
if (parsed.id > lastLogID.value) lastLogID.value = parsed.id
|
||||||
|
logs.value.push({
|
||||||
|
id: parsed.id,
|
||||||
|
level: parsed.level ?? 'info',
|
||||||
|
text: parsed.text ?? '',
|
||||||
|
ts: parsed.ts ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.onerror = () => {
|
||||||
|
status.value = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.onclose = () => {
|
||||||
|
ws = null
|
||||||
|
if (manualClose) {
|
||||||
|
status.value = 'closed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (retried < 2) {
|
||||||
|
const delay = retried === 0 ? 1000 : 3000
|
||||||
|
retried += 1
|
||||||
|
reconnectTimer = setTimeout(connect, delay)
|
||||||
|
} else {
|
||||||
|
status.value = 'closed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnect() {
|
||||||
|
if (ws) ws.close()
|
||||||
|
retried = 0
|
||||||
|
manualClose = false
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
manualClose = true
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (ws) ws.close()
|
||||||
|
status.value = 'closed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
connect()
|
||||||
|
onScopeDispose(close)
|
||||||
|
|
||||||
|
return { status, runStatus, logs, reconnect, close, clear }
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
runsApi,
|
||||||
|
type RunProfile,
|
||||||
|
type RunStatus,
|
||||||
|
type CreateRunProfileBody,
|
||||||
|
type UpdateRunProfileBody,
|
||||||
|
} from '@/api/runs'
|
||||||
|
|
||||||
|
export const useRunsStore = defineStore('runs', () => {
|
||||||
|
const profiles = ref<RunProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetch(wsID: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
profiles.value = await runsApi.list(wsID)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(wsID: string, body: CreateRunProfileBody) {
|
||||||
|
const p = await runsApi.create(wsID, body)
|
||||||
|
profiles.value.unshift(p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: string, body: UpdateRunProfileBody) {
|
||||||
|
const p = await runsApi.update(id, body)
|
||||||
|
replace(p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await runsApi.remove(id)
|
||||||
|
profiles.value = profiles.value.filter((x) => x.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(id: string) {
|
||||||
|
return applyStatus(id, await runsApi.start(id))
|
||||||
|
}
|
||||||
|
async function stop(id: string) {
|
||||||
|
return applyStatus(id, await runsApi.stop(id))
|
||||||
|
}
|
||||||
|
async function restart(id: string) {
|
||||||
|
return applyStatus(id, await runsApi.restart(id))
|
||||||
|
}
|
||||||
|
async function refreshStatus(id: string) {
|
||||||
|
return applyStatus(id, await runsApi.status(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace(p: RunProfile) {
|
||||||
|
profiles.value = profiles.value.map((x) => (x.id === p.id ? p : x))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyStatus 用 start/stop/restart/status 返回的轻量状态更新对应行的运行态字段。
|
||||||
|
function applyStatus(id: string, s: RunStatus): RunStatus {
|
||||||
|
profiles.value = profiles.value.map((x) =>
|
||||||
|
x.id === id
|
||||||
|
? {
|
||||||
|
...x,
|
||||||
|
status: s.status,
|
||||||
|
started_at: s.started_at,
|
||||||
|
last_exit_code: s.last_exit_code,
|
||||||
|
last_error: s.last_error,
|
||||||
|
}
|
||||||
|
: x,
|
||||||
|
)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return { profiles, loading, fetch, create, update, remove, start, stop, restart, refreshStatus }
|
||||||
|
})
|
||||||
@@ -34,6 +34,15 @@
|
|||||||
:ws="store.current"
|
:ws="store.current"
|
||||||
/>
|
/>
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
<NTabPane
|
||||||
|
name="runs"
|
||||||
|
tab="Run"
|
||||||
|
>
|
||||||
|
<RunsTab
|
||||||
|
v-if="store.current"
|
||||||
|
:ws="store.current"
|
||||||
|
/>
|
||||||
|
</NTabPane>
|
||||||
<NTabPane
|
<NTabPane
|
||||||
name="settings"
|
name="settings"
|
||||||
tab="Settings"
|
tab="Settings"
|
||||||
@@ -58,11 +67,12 @@ import SyncBar from './components/SyncBar.vue'
|
|||||||
import WorktreesTab from './components/WorktreesTab.vue'
|
import WorktreesTab from './components/WorktreesTab.vue'
|
||||||
import MainTab from './components/MainTab.vue'
|
import MainTab from './components/MainTab.vue'
|
||||||
import SettingsTab from './components/SettingsTab.vue'
|
import SettingsTab from './components/SettingsTab.vue'
|
||||||
|
import RunsTab from './components/RunsTab.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useWorkspacesStore()
|
const store = useWorkspacesStore()
|
||||||
const msg = useMessage()
|
const msg = useMessage()
|
||||||
const tab = ref<'worktrees' | 'main' | 'settings'>('worktrees')
|
const tab = ref<'worktrees' | 'main' | 'settings' | 'runs'>('worktrees')
|
||||||
|
|
||||||
const projectSlug = computed(() => route.params.slug as string)
|
const projectSlug = computed(() => route.params.slug as string)
|
||||||
const wsSlug = computed(() => route.params.wsSlug as string)
|
const wsSlug = computed(() => route.params.wsSlug as string)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border rounded bg-gray-900 text-gray-100">
|
||||||
|
<div class="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 text-xs">
|
||||||
|
<span>
|
||||||
|
日志 ·
|
||||||
|
<span :class="connClass">{{ connText }}</span>
|
||||||
|
</span>
|
||||||
|
<NButton
|
||||||
|
text
|
||||||
|
size="tiny"
|
||||||
|
class="!text-gray-300"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
清屏
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NScrollbar
|
||||||
|
ref="scrollRef"
|
||||||
|
:style="{ maxHeight: '360px' }"
|
||||||
|
>
|
||||||
|
<div class="p-3 font-mono text-xs leading-relaxed">
|
||||||
|
<div
|
||||||
|
v-for="l in logs"
|
||||||
|
:key="l.id"
|
||||||
|
:class="l.level === 'error' ? 'text-red-400' : 'text-gray-200'"
|
||||||
|
class="whitespace-pre-wrap break-all"
|
||||||
|
>
|
||||||
|
{{ l.text }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="logs.length === 0"
|
||||||
|
class="text-gray-500"
|
||||||
|
>
|
||||||
|
暂无日志(进程未运行或尚无输出)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NScrollbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { NScrollbar, NButton } from 'naive-ui'
|
||||||
|
import { useRunStream } from '@/composables/useRunStream'
|
||||||
|
|
||||||
|
const props = defineProps<{ profileId: string }>()
|
||||||
|
|
||||||
|
const { status, logs, clear } = useRunStream(props.profileId)
|
||||||
|
|
||||||
|
const scrollRef = ref<InstanceType<typeof NScrollbar> | null>(null)
|
||||||
|
|
||||||
|
// 新日志到达后自动滚到底部。
|
||||||
|
watch(
|
||||||
|
() => logs.value.length,
|
||||||
|
() => {
|
||||||
|
nextTick(() => scrollRef.value?.scrollTo({ top: 999_999_999 }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const connText = computed(() => {
|
||||||
|
switch (status.value) {
|
||||||
|
case 'streaming':
|
||||||
|
return '已连接'
|
||||||
|
case 'connecting':
|
||||||
|
return '连接中…'
|
||||||
|
case 'error':
|
||||||
|
return '连接异常'
|
||||||
|
default:
|
||||||
|
return '已断开'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const connClass = computed(() =>
|
||||||
|
status.value === 'streaming'
|
||||||
|
? 'text-green-400'
|
||||||
|
: status.value === 'error'
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-gray-400',
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<NModal
|
||||||
|
:show="true"
|
||||||
|
preset="card"
|
||||||
|
:title="profile ? '编辑运行档' : '新建运行档'"
|
||||||
|
:style="{ width: '640px' }"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<NForm label-placement="top">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<NFormItem
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.slug"
|
||||||
|
placeholder="dev"
|
||||||
|
:disabled="!!profile"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem
|
||||||
|
label="名称"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.name"
|
||||||
|
placeholder="Dev server"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NFormItem label="描述">
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 3 }"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem
|
||||||
|
label="启动命令"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.command"
|
||||||
|
placeholder="pnpm"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="参数(每行一个)">
|
||||||
|
<NInput
|
||||||
|
v-model:value="argsText"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||||
|
placeholder="dev"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="环境变量">
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
v-if="profile && !editEnv"
|
||||||
|
class="text-sm text-gray-500 mb-2"
|
||||||
|
>
|
||||||
|
<span v-if="profile.env_keys.length">已配置:{{ profile.env_keys.join(', ') }}</span>
|
||||||
|
<span v-else>(无)</span>
|
||||||
|
<NButton
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
@click="enableEnvEdit"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NDynamicInput
|
||||||
|
v-else
|
||||||
|
v-model:value="envPairs"
|
||||||
|
preset="pair"
|
||||||
|
key-placeholder="KEY"
|
||||||
|
value-placeholder="value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem>
|
||||||
|
<NCheckbox v-model:checked="form.enabled">
|
||||||
|
启用(禁用后不可 start)
|
||||||
|
</NCheckbox>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="emit('close')">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import {
|
||||||
|
NModal,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NDynamicInput,
|
||||||
|
NCheckbox,
|
||||||
|
NButton,
|
||||||
|
NSpace,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { ApiException } from '@/api/types'
|
||||||
|
import { useRunsStore } from '@/stores/runs'
|
||||||
|
import type { RunProfile, CreateRunProfileBody, UpdateRunProfileBody } from '@/api/runs'
|
||||||
|
|
||||||
|
const props = defineProps<{ wsId: string; profile?: RunProfile | null }>()
|
||||||
|
const emit = defineEmits<{ close: []; saved: [p: RunProfile] }>()
|
||||||
|
const store = useRunsStore()
|
||||||
|
const msg = useMessage()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
slug: props.profile?.slug ?? '',
|
||||||
|
name: props.profile?.name ?? '',
|
||||||
|
description: props.profile?.description ?? '',
|
||||||
|
command: props.profile?.command ?? '',
|
||||||
|
enabled: props.profile?.enabled ?? true,
|
||||||
|
})
|
||||||
|
const argsText = ref<string>((props.profile?.args ?? []).join('\n'))
|
||||||
|
const envPairs = ref<{ key: string; value: string }[]>([])
|
||||||
|
// 编辑模式下默认不改 env(server 不回传明文值);点"修改"后才整体替换。
|
||||||
|
const editEnv = ref(!props.profile)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
function enableEnvEdit() {
|
||||||
|
editEnv.value = true
|
||||||
|
envPairs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedArgs(): string[] {
|
||||||
|
return argsText.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsedEnv(): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {}
|
||||||
|
for (const p of envPairs.value) {
|
||||||
|
if (p.key.trim()) env[p.key.trim()] = p.value
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.slug.trim() || !form.name.trim() || !form.command.trim()) {
|
||||||
|
msg.warning('slug / 名称 / 启动命令为必填')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
let saved: RunProfile
|
||||||
|
if (props.profile) {
|
||||||
|
const body: UpdateRunProfileBody = {
|
||||||
|
slug: form.slug,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
command: form.command,
|
||||||
|
args: parsedArgs(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
// 仅在用户显式修改时才整体替换 env,否则保留原值。
|
||||||
|
if (editEnv.value) body.env = parsedEnv()
|
||||||
|
saved = await store.update(props.profile.id, body)
|
||||||
|
} else {
|
||||||
|
const body: CreateRunProfileBody = {
|
||||||
|
slug: form.slug,
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
command: form.command,
|
||||||
|
args: parsedArgs(),
|
||||||
|
env: parsedEnv(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
saved = await store.create(props.wsId, body)
|
||||||
|
}
|
||||||
|
emit('saved', saved)
|
||||||
|
emit('close')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error('保存失败:' + (e instanceof ApiException ? e.body.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">在 main 工作区托管启动的运行档</span>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
新建运行档
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="store.profiles"
|
||||||
|
:loading="store.loading"
|
||||||
|
:row-key="(row: RunProfile) => row.id"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="selectedId">
|
||||||
|
<RunLogViewer
|
||||||
|
:key="selectedId"
|
||||||
|
:profile-id="selectedId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RunProfileDialog
|
||||||
|
v-if="dialogOpen"
|
||||||
|
:ws-id="ws.id"
|
||||||
|
:profile="editing"
|
||||||
|
@close="dialogOpen = false"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NDataTable,
|
||||||
|
NSpace,
|
||||||
|
NTag,
|
||||||
|
useMessage,
|
||||||
|
useDialog,
|
||||||
|
type DataTableColumns,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { ApiException } from '@/api/types'
|
||||||
|
import type { Workspace } from '@/api/types'
|
||||||
|
import { useRunsStore } from '@/stores/runs'
|
||||||
|
import type { RunProfile, RunState } from '@/api/runs'
|
||||||
|
import RunProfileDialog from './RunProfileDialog.vue'
|
||||||
|
import RunLogViewer from './RunLogViewer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ ws: Workspace }>()
|
||||||
|
const store = useRunsStore()
|
||||||
|
const msg = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const editing = ref<RunProfile | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetch(props.ws.id).catch((e: unknown) => msg.error('加载运行档失败:' + errText(e)))
|
||||||
|
})
|
||||||
|
|
||||||
|
function errText(e: unknown): string {
|
||||||
|
return e instanceof ApiException ? e.body.message : e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagType: Record<RunState, 'success' | 'default' | 'error'> = {
|
||||||
|
running: 'success',
|
||||||
|
stopped: 'default',
|
||||||
|
crashed: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
function openEdit(p: RunProfile) {
|
||||||
|
editing.value = p
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
function onSaved() {
|
||||||
|
dialogOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
msg.success(ok)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error(errText(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(p: RunProfile) {
|
||||||
|
dialog.warning({
|
||||||
|
title: '删除运行档',
|
||||||
|
content: `确定删除「${p.name}」?运行中的进程会被先停止。`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await act(() => store.remove(p.id), '已删除')
|
||||||
|
if (selectedId.value === p.id) selectedId.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<RunProfile> = [
|
||||||
|
{ title: '名称', key: 'name' },
|
||||||
|
{ title: 'Slug', key: 'slug' },
|
||||||
|
{ title: '命令', key: 'command', ellipsis: { tooltip: true } },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (row) =>
|
||||||
|
h(NTag, { type: tagType[row.status], size: 'small' }, { default: () => row.status }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (row) =>
|
||||||
|
h(NSpace, { size: 'small' }, () => [
|
||||||
|
row.status === 'running'
|
||||||
|
? h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', onClick: () => act(() => store.stop(row.id), '已停止') },
|
||||||
|
() => '停止',
|
||||||
|
)
|
||||||
|
: h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'tiny',
|
||||||
|
type: 'primary',
|
||||||
|
disabled: !row.enabled,
|
||||||
|
onClick: () => act(() => store.start(row.id), '已启动'),
|
||||||
|
},
|
||||||
|
() => '启动',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', onClick: () => act(() => store.restart(row.id), '已重启') },
|
||||||
|
() => '重启',
|
||||||
|
),
|
||||||
|
h(NButton, { size: 'tiny', onClick: () => (selectedId.value = row.id) }, () => '日志'),
|
||||||
|
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, () => '编辑'),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'tiny', type: 'error', onClick: () => confirmDelete(row) },
|
||||||
|
() => '删除',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user