You've already forked agentic-coding-workflow
ACP / MCP 完善
This commit is contained in:
@@ -101,6 +101,9 @@ func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, str
|
|||||||
func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
|
func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
|
func (f *fakeAgentKindRepo) MarkSessionFailedIfActive(context.Context, uuid.UUID, string) (bool, error) {
|
||||||
|
panic("n/a")
|
||||||
|
}
|
||||||
func (f *fakeAgentKindRepo) CountActiveSessions(context.Context) (int64, error) {
|
func (f *fakeAgentKindRepo) CountActiveSessions(context.Context) (int64, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type AgentKind struct {
|
|||||||
Args []string
|
Args []string
|
||||||
EncryptedEnv []byte
|
EncryptedEnv []byte
|
||||||
Enabled bool
|
Enabled bool
|
||||||
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示全部放行
|
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示放行全部可识别的工具(无法识别名称的调用仍走人工审批)
|
||||||
CreatedBy uuid.UUID
|
CreatedBy uuid.UUID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/acp/pathsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FsReadHandler handles fs/read_text_file (spec §5.4).
|
// FsReadHandler handles fs/read_text_file (spec §5.4).
|
||||||
@@ -75,34 +75,9 @@ func sliceLines(s string, line, limit *int) string {
|
|||||||
return strings.Join(lines[start:end], "\n")
|
return strings.Join(lines[start:end], "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSafePath duplicates the logic from internal/acp.ResolveSafePath to
|
// resolveSafePath 委托共享实现 pathsafe.ResolveSafePath。
|
||||||
// avoid an import cycle (handlers is a sub-package and acp imports handlers).
|
// handlers 子包不能 import internal/acp 主包(acp 依赖 handlers,会循环),
|
||||||
|
// 故校验逻辑抽到 internal/acp/pathsafe 子包共用。
|
||||||
func resolveSafePath(cwd, requested string) (string, error) {
|
func resolveSafePath(cwd, requested string) (string, error) {
|
||||||
if !filepath.IsAbs(cwd) {
|
return pathsafe.ResolveSafePath(cwd, requested)
|
||||||
return "", fmt.Errorf("cwd must be absolute")
|
|
||||||
}
|
|
||||||
cleanCwd := filepath.Clean(cwd)
|
|
||||||
var abs string
|
|
||||||
if filepath.IsAbs(requested) {
|
|
||||||
abs = filepath.Clean(requested)
|
|
||||||
} else {
|
|
||||||
abs = filepath.Clean(filepath.Join(cleanCwd, requested))
|
|
||||||
}
|
|
||||||
if !hasPathPrefix(abs, cleanCwd) {
|
|
||||||
return "", fmt.Errorf("path outside worktree")
|
|
||||||
}
|
|
||||||
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
|
||||||
if !hasPathPrefix(real, cleanCwd) {
|
|
||||||
return "", fmt.Errorf("path resolves outside worktree via symlink")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return abs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasPathPrefix(abs, base string) bool {
|
|
||||||
if abs == base {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
sep := string(filepath.Separator)
|
|
||||||
return strings.HasPrefix(abs, base+sep)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,28 @@ func TestFsWrite_OutsideWorktree(t *testing.T) {
|
|||||||
require.NotNil(t, res.Err)
|
require.NotNil(t, res.Err)
|
||||||
assert.Equal(t, -32001, res.Err.Code)
|
assert.Equal(t, -32001, res.Err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 父目录是 worktree 内指向外部的 symlink、目标文件不存在 → 必须被拒,
|
||||||
|
// 且外部目录不能出现新建文件(防 symlink 逃逸写出 worktree)。
|
||||||
|
func TestFsWrite_SymlinkDirEscape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
link := filepath.Join(tmp, "link-dir")
|
||||||
|
if err := os.Symlink(outside, link); err != nil {
|
||||||
|
t.Skipf("symlink not supported on this platform: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := handlers.NewFsWriteHandler()
|
||||||
|
sess := handlers.SessionContext{CwdPath: tmp}
|
||||||
|
params := mustJSON(t, map[string]any{
|
||||||
|
"sessionId": "x", "path": filepath.Join(link, "escape.txt"), "content": "evil",
|
||||||
|
})
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.NotNil(t, res.Err)
|
||||||
|
assert.Equal(t, -32001, res.Err.Code)
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(outside, "escape.txt"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "外部目录不应被写入")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Package pathsafe 实现 ACP fs/* method 的路径安全校验。
|
||||||
|
//
|
||||||
|
// 单独成包是因为 internal/acp/handlers 子包不能 import internal/acp 主包
|
||||||
|
// (acp 依赖 handlers,会循环),故抽到共享子包供两边复用。
|
||||||
|
//
|
||||||
|
// 规则(spec §5.6):
|
||||||
|
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
||||||
|
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸);
|
||||||
|
// 目标不存在时(如 fs/write_text_file 新建文件),向上找最近已存在的
|
||||||
|
// 祖先目录解析真实路径后再校验,防经由指向外部的目录 symlink 写出 worktree。
|
||||||
|
//
|
||||||
|
// 三重校验:filepath.Abs + hasPathPrefix + filepath.EvalSymlinks。
|
||||||
|
package pathsafe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveSafePath 校验 requested 是否安全(在 cwd 内)。
|
||||||
|
// cwd 必须是绝对路径;requested 可以是绝对或相对(相对时基于 cwd 解析)。
|
||||||
|
// 返回校验通过后的绝对路径(含 symlink 解析);失败返回 CodeAcpFsPathOutsideWorktree。
|
||||||
|
func ResolveSafePath(cwd, requested string) (string, error) {
|
||||||
|
if !filepath.IsAbs(cwd) {
|
||||||
|
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "cwd must be absolute")
|
||||||
|
}
|
||||||
|
cleanCwd := filepath.Clean(cwd)
|
||||||
|
|
||||||
|
var abs string
|
||||||
|
if filepath.IsAbs(requested) {
|
||||||
|
abs = filepath.Clean(requested)
|
||||||
|
} else {
|
||||||
|
abs = filepath.Clean(filepath.Join(cleanCwd, requested))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPathPrefix(abs, cleanCwd) {
|
||||||
|
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path outside worktree")
|
||||||
|
}
|
||||||
|
|
||||||
|
real, err := filepath.EvalSymlinks(abs)
|
||||||
|
if err != nil {
|
||||||
|
// 目标(或部分祖先)不存在:不能静默放行,否则父目录若是指向外部的
|
||||||
|
// symlink,后续 MkdirAll/WriteFile 会跟随 symlink 写出 worktree。
|
||||||
|
// 改为向上找最近已存在的祖先解析真实路径,再拼回剩余部分校验。
|
||||||
|
real, err = evalNearestAncestor(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err, errs.CodeAcpFsPathOutsideWorktree, "resolve path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPathPrefix(real, cleanCwd) {
|
||||||
|
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path resolves outside worktree via symlink")
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalNearestAncestor 自 abs 向上找最近已存在(EvalSymlinks 成功)的祖先目录,
|
||||||
|
// 用其真实路径拼回剩余相对部分,得到 abs 对应的真实路径。
|
||||||
|
// 直到根目录仍无法解析时返回错误(调用方按路径不安全处理)。
|
||||||
|
func evalNearestAncestor(abs string) (string, error) {
|
||||||
|
cur := abs
|
||||||
|
rest := ""
|
||||||
|
for {
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
// 已到根目录仍解析失败(如盘符/根不存在)
|
||||||
|
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "no existing ancestor for path")
|
||||||
|
}
|
||||||
|
if rest == "" {
|
||||||
|
rest = filepath.Base(cur)
|
||||||
|
} else {
|
||||||
|
rest = filepath.Join(filepath.Base(cur), rest)
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
if real, err := filepath.EvalSymlinks(cur); err == nil {
|
||||||
|
return filepath.Join(real, rest), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPathPrefix reports whether abs is equal to base or under base. Trailing
|
||||||
|
// separator handling is critical: we want "/foo/bar" to be inside "/foo" but
|
||||||
|
// NOT inside "/fo".
|
||||||
|
func hasPathPrefix(abs, base string) bool {
|
||||||
|
if abs == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
return strings.HasPrefix(abs, base+sep)
|
||||||
|
}
|
||||||
@@ -1,55 +1,16 @@
|
|||||||
// permission.go 实现 ACP fs/* method 的路径安全校验。
|
// permission.go 暴露 ACP fs/* method 的路径安全校验入口。
|
||||||
//
|
//
|
||||||
// 规则(spec §5.6):
|
// 具体校验逻辑在 internal/acp/pathsafe 子包(与 handlers 子包共用同一实现,
|
||||||
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
// 详见 pathsafe 包注释)。
|
||||||
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
|
|
||||||
//
|
|
||||||
// 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。
|
|
||||||
package acp
|
package acp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"github.com/yan1h/agent-coding-workflow/internal/acp/pathsafe"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveSafePath 校验 requested 是否安全(在 cwd 内)。
|
// ResolveSafePath 校验 requested 是否安全(在 cwd 内)。
|
||||||
// cwd 必须是绝对路径;requested 可以是绝对或相对(相对时基于 cwd 解析)。
|
// cwd 必须是绝对路径;requested 可以是绝对或相对(相对时基于 cwd 解析)。
|
||||||
// 返回校验通过后的绝对路径(含 symlink 解析);失败返回 CodeAcpFsPathOutsideWorktree。
|
// 返回校验通过后的绝对路径(含 symlink 解析);失败返回 CodeAcpFsPathOutsideWorktree。
|
||||||
func ResolveSafePath(cwd, requested string) (string, error) {
|
func ResolveSafePath(cwd, requested string) (string, error) {
|
||||||
if !filepath.IsAbs(cwd) {
|
return pathsafe.ResolveSafePath(cwd, requested)
|
||||||
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "cwd must be absolute")
|
|
||||||
}
|
|
||||||
cleanCwd := filepath.Clean(cwd)
|
|
||||||
|
|
||||||
var abs string
|
|
||||||
if filepath.IsAbs(requested) {
|
|
||||||
abs = filepath.Clean(requested)
|
|
||||||
} else {
|
|
||||||
abs = filepath.Clean(filepath.Join(cleanCwd, requested))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasPathPrefix(abs, cleanCwd) {
|
|
||||||
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path outside worktree")
|
|
||||||
}
|
|
||||||
|
|
||||||
real, err := filepath.EvalSymlinks(abs)
|
|
||||||
if err == nil {
|
|
||||||
if !hasPathPrefix(real, cleanCwd) {
|
|
||||||
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path resolves outside worktree via symlink")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return abs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasPathPrefix reports whether abs is equal to base or under base. Trailing
|
|
||||||
// separator handling is critical: we want "/foo/bar" to be inside "/foo" but
|
|
||||||
// NOT inside "/fo".
|
|
||||||
func hasPathPrefix(abs, base string) bool {
|
|
||||||
if abs == base {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
sep := string(filepath.Separator)
|
|
||||||
return strings.HasPrefix(abs, base+sep)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,13 +332,14 @@ func extractToolName(toolCall json.RawMessage) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowlistHit 判断工具是否在白名单内。含 "*" 表示全部放行;toolName 为空时不放行。
|
// allowlistHit 判断工具是否在白名单内。含 "*" 表示放行全部可识别的工具;
|
||||||
|
// toolName 为空(无法识别的 tool call)时一律不放行,保守走人工审批。
|
||||||
func allowlistHit(allowlist []string, toolName string) bool {
|
func allowlistHit(allowlist []string, toolName string) bool {
|
||||||
for _, a := range allowlist {
|
if toolName == "" {
|
||||||
if a == "*" {
|
return false
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if toolName != "" && a == toolName {
|
for _, a := range allowlist {
|
||||||
|
if a == "*" || a == toolName {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package acp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestAllowlistHit 覆盖白名单匹配的边界:空 toolName 一律不放行(含 "*"),
|
||||||
|
// 白名单中的空串条目不误匹配。
|
||||||
|
func TestAllowlistHit(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
allowlist []string
|
||||||
|
toolName string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"精确命中", []string{"safe.tool"}, "safe.tool", true},
|
||||||
|
{"未命中", []string{"safe.tool"}, "other.tool", false},
|
||||||
|
{"通配符放行可识别工具", []string{"*"}, "any.tool", true},
|
||||||
|
{"通配符不放行空名", []string{"*"}, "", false},
|
||||||
|
{"空名不匹配空串条目", []string{""}, "", false},
|
||||||
|
{"空白名单", nil, "any.tool", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := allowlistHit(c.allowlist, c.toolName); got != c.want {
|
||||||
|
t.Fatalf("allowlistHit(%v, %q) = %v, want %v", c.allowlist, c.toolName, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,51 @@ func TestPermission_AutoAllow_WhenAllowlisted(t *testing.T) {
|
|||||||
require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID()))
|
require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPermission_AutoAllow_Wildcard(t *testing.T) {
|
||||||
|
repo := newPermFakeRepo()
|
||||||
|
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||||
|
sess := handlers.SessionContext{
|
||||||
|
SessionID: uuid.New(),
|
||||||
|
UserID: uuid.New(),
|
||||||
|
ToolAllowlist: []string{"*"},
|
||||||
|
}
|
||||||
|
chosen := svc.Decide(context.Background(), sess, "req-1",
|
||||||
|
[]byte(`{"name":"any.tool"}`), optionsAllowReject())
|
||||||
|
require.Equal(t, "allow_once", chosen)
|
||||||
|
require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermission_Wildcard_UnknownToolName_Pending(t *testing.T) {
|
||||||
|
// allowlist 含 "*" 但 toolCall 无法识别名称 → 不自动放行,走人工审批。
|
||||||
|
repo := newPermFakeRepo()
|
||||||
|
uid := uuid.New()
|
||||||
|
sid := uuid.New()
|
||||||
|
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
|
||||||
|
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
|
||||||
|
sess := handlers.SessionContext{SessionID: sid, UserID: uid, ToolAllowlist: []string{"*"}}
|
||||||
|
|
||||||
|
resCh := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
resCh <- svc.Decide(context.Background(), sess, "req-1",
|
||||||
|
[]byte(`{"unknownField":1}`), optionsAllowReject())
|
||||||
|
}()
|
||||||
|
|
||||||
|
var reqID uuid.UUID
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
reqID = repo.onlyReqID()
|
||||||
|
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
|
||||||
|
}, time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once"))
|
||||||
|
select {
|
||||||
|
case chosen := <-resCh:
|
||||||
|
require.Equal(t, "allow_once", chosen)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Decide 未在批准后返回")
|
||||||
|
}
|
||||||
|
require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID))
|
||||||
|
}
|
||||||
|
|
||||||
func TestPermission_Pending_ThenApprove(t *testing.T) {
|
func TestPermission_Pending_ThenApprove(t *testing.T) {
|
||||||
repo := newPermFakeRepo()
|
repo := newPermFakeRepo()
|
||||||
uid := uuid.New()
|
uid := uuid.New()
|
||||||
|
|||||||
@@ -94,6 +94,37 @@ func TestResolveSafePath_NonexistentInsideOK(t *testing.T) {
|
|||||||
got, err := acp.ResolveSafePath(tmp, want)
|
got, err := acp.ResolveSafePath(tmp, want)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
|
|
||||||
|
// 多级不存在的中间目录(祖先均为 cwd 内真实目录)同样合法,不能误杀
|
||||||
|
want = filepath.Join(tmp, "newdir", "sub", "newfile.txt")
|
||||||
|
got, err = acp.ResolveSafePath(tmp, want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父目录是指向外部的 symlink 且叶子文件不存在 → 必须被拒,
|
||||||
|
// 否则 fs/write_text_file 会跟随 symlink 把新建文件写出 worktree。
|
||||||
|
func TestResolveSafePath_SymlinkDirEscapeNonexistentLeaf(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
|
link := filepath.Join(tmp, "link-dir")
|
||||||
|
if err := os.Symlink(outside, link); err != nil {
|
||||||
|
t.Skipf("symlink not supported on this platform: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 叶子不存在
|
||||||
|
_, err := acp.ResolveSafePath(tmp, filepath.Join(link, "newfile.txt"))
|
||||||
|
ae, ok := errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
|
||||||
|
|
||||||
|
// 叶子与中间目录都不存在(symlink 是更远的祖先)同样被拒
|
||||||
|
_, err = acp.ResolveSafePath(tmp, filepath.Join(link, "sub", "newfile.txt"))
|
||||||
|
ae, ok = errs.As(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveSafePath_RelativeCwdRejected(t *testing.T) {
|
func TestResolveSafePath_RelativeCwdRejected(t *testing.T) {
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ UPDATE acp_sessions
|
|||||||
SET status = $2, exit_code = $3, last_error = $4, ended_at = now()
|
SET status = $2, exit_code = $3, last_error = $4, ended_at = now()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkSessionFailedIfActive :execrows
|
||||||
|
UPDATE acp_sessions
|
||||||
|
SET status = 'crashed', last_error = $2, ended_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('starting', 'running');
|
||||||
|
|
||||||
-- name: CountActiveSessions :one
|
-- name: CountActiveSessions :one
|
||||||
SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running');
|
SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running');
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string,
|
|||||||
func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
|
func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
|
func (f *fakeEventRepo) MarkSessionFailedIfActive(context.Context, uuid.UUID, string) (bool, error) {
|
||||||
|
panic("n/a")
|
||||||
|
}
|
||||||
func (f *fakeEventRepo) CountActiveSessions(context.Context) (int64, error) { panic("n/a") }
|
func (f *fakeEventRepo) CountActiveSessions(context.Context) (int64, error) { panic("n/a") }
|
||||||
func (f *fakeEventRepo) CountActiveSessionsByUser(context.Context, uuid.UUID) (int64, error) {
|
func (f *fakeEventRepo) CountActiveSessionsByUser(context.Context, uuid.UUID) (int64, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type Repository interface {
|
|||||||
ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error)
|
ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error)
|
||||||
UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error
|
UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error
|
||||||
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
|
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
|
||||||
|
// MarkSessionFailedIfActive 把仍处于 starting/running 的 session 标记为 crashed
|
||||||
|
// 并写入 lastErr;已是终态时不更新(守卫式,避免覆盖 onExit 写入的终态)。
|
||||||
|
MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, lastErr string) (bool, error)
|
||||||
CountActiveSessions(ctx context.Context) (int64, error)
|
CountActiveSessions(ctx context.Context) (int64, error)
|
||||||
CountActiveSessionsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
CountActiveSessionsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
|
||||||
ResetStuckSessionsOnRestart(ctx context.Context) ([]*Session, error)
|
ResetStuckSessionsOnRestart(ctx context.Context) ([]*Session, error)
|
||||||
@@ -339,6 +342,17 @@ func (r *pgRepo) UpdateSessionFinished(ctx context.Context, id uuid.UUID, status
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepo) MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, lastErr string) (bool, error) {
|
||||||
|
n, err := r.q.MarkSessionFailedIfActive(ctx, acpsqlc.MarkSessionFailedIfActiveParams{
|
||||||
|
ID: toPgUUID(id),
|
||||||
|
LastError: &lastErr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.Wrap(err, errs.CodeInternal, "mark session failed if active")
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *pgRepo) CountActiveSessions(ctx context.Context) (int64, error) {
|
func (r *pgRepo) CountActiveSessions(ctx context.Context) (int64, error) {
|
||||||
n, err := r.q.CountActiveSessions(ctx)
|
n, err := r.q.CountActiveSessions(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ func ResolveBranch(ctx context.Context, ws *workspace.Workspace, sessionID uuid.
|
|||||||
// 9. async supervisor.Spawn(失败回滚 worktree)
|
// 9. async supervisor.Spawn(失败回滚 worktree)
|
||||||
// 10. async initial_prompt 转发(等握手完成)
|
// 10. async initial_prompt 转发(等握手完成)
|
||||||
func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) {
|
func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionInput) (*Session, error) {
|
||||||
|
// 0. 互斥校验:branch / issue_number / requirement_number 三选一或全空(spec §8.1)。
|
||||||
|
// service 层统一拦截,HTTP / MCP 入口共用。
|
||||||
|
specified := 0
|
||||||
|
if in.Branch != nil && *in.Branch != "" {
|
||||||
|
specified++
|
||||||
|
}
|
||||||
|
if in.IssueNumber != nil {
|
||||||
|
specified++
|
||||||
|
}
|
||||||
|
if in.RequirementNumber != nil {
|
||||||
|
specified++
|
||||||
|
}
|
||||||
|
if specified > 1 {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput,
|
||||||
|
"branch / issue_number / requirement_number are mutually exclusive; specify at most one")
|
||||||
|
}
|
||||||
|
|
||||||
// 1. workspace + authz
|
// 1. workspace + authz
|
||||||
wsCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}
|
wsCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}
|
||||||
ws, err := s.wsSvc.Get(ctx, wsCaller, in.WorkspaceID)
|
ws, err := s.wsSvc.Get(ctx, wsCaller, in.WorkspaceID)
|
||||||
@@ -251,6 +268,12 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
|
|||||||
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := s.sup.Spawn(spawnCtx, out, kind, extraEnv); err != nil {
|
if _, err := s.sup.Spawn(spawnCtx, out, kind, extraEnv); err != nil {
|
||||||
|
// 守卫式标记:Spawn 早期失败(进程未注册、onExit 不会触发)时把
|
||||||
|
// starting 收尾为 crashed;handshake 失败路径 onExit 已写终态,此处 no-op。
|
||||||
|
if _, merr := s.repo.MarkSessionFailedIfActive(spawnCtx, out.ID, err.Error()); merr != nil {
|
||||||
|
s.sup.log.Error("acp.spawn_failed.mark_session",
|
||||||
|
"session_id", out.ID, "err", merr.Error())
|
||||||
|
}
|
||||||
_ = s.mcpTokens.RevokeBySession(spawnCtx, out.ID)
|
_ = s.mcpTokens.RevokeBySession(spawnCtx, out.ID)
|
||||||
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
|
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,19 @@ func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ str
|
|||||||
func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error {
|
func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (r *fakeAcpRepo) MarkSessionFailedIfActive(_ context.Context, id uuid.UUID, lastErr string) (bool, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, s := range r.sessions {
|
||||||
|
if s.ID == id && s.Status.IsActive() {
|
||||||
|
s.Status = acp.SessionCrashed
|
||||||
|
msg := lastErr
|
||||||
|
s.LastError = &msg
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
func (r *fakeAcpRepo) ResetStuckSessionsOnRestart(_ context.Context) ([]*acp.Session, error) {
|
func (r *fakeAcpRepo) ResetStuckSessionsOnRestart(_ context.Context) ([]*acp.Session, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -444,6 +457,101 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) {
|
|||||||
repo.mu.Unlock()
|
repo.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionService_Create_SpawnFails_MarksSessionCrashed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
uid := uuid.New()
|
||||||
|
wsID := uuid.New()
|
||||||
|
pid := uuid.New()
|
||||||
|
akID := uuid.New()
|
||||||
|
|
||||||
|
repo := &fakeAcpRepo{
|
||||||
|
kinds: []*acp.AgentKind{
|
||||||
|
// BinaryPath 不存在 → supervisor.Spawn 在 cmd.Start 早期失败,
|
||||||
|
// 进程未注册、onExit 不会触发。
|
||||||
|
{ID: akID, Name: "claude", Enabled: true, BinaryPath: "/nonexistent/agent-binary"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wsSvc := &fakeWsSvc{ws: &workspace.Workspace{
|
||||||
|
ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main",
|
||||||
|
}}
|
||||||
|
pa := fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "test-proj"}}
|
||||||
|
mcpTokens := &fakeMCPTokenSvc{}
|
||||||
|
|
||||||
|
sup := acp.NewSupervisor(
|
||||||
|
repo, nil, nil, nil, nil, mcpTokens, nil,
|
||||||
|
acp.SupervisorConfig{SpawnTimeout: 5 * time.Second, KillGrace: 1 * time.Second},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
svc := acp.NewSessionService(
|
||||||
|
repo, wsSvc, nil, nil, pa, sup, nil,
|
||||||
|
acp.SessionServiceConfig{
|
||||||
|
MaxActiveGlobal: 10,
|
||||||
|
MaxActivePerUser: 5,
|
||||||
|
SystemTokenTTL: 24 * time.Hour,
|
||||||
|
MCPPublicURL: "http://localhost:8080/mcp",
|
||||||
|
},
|
||||||
|
mcpTokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
branch := "main"
|
||||||
|
sess, err := svc.Create(context.Background(), acp.Caller{UserID: uid}, acp.CreateSessionInput{
|
||||||
|
WorkspaceID: wsID,
|
||||||
|
AgentKindID: akID,
|
||||||
|
Branch: &branch,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, sess)
|
||||||
|
|
||||||
|
// async spawn 失败后:session 被守卫式标记为 crashed 且 last_error 非空
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
repo.mu.Lock()
|
||||||
|
defer repo.mu.Unlock()
|
||||||
|
for _, s := range repo.sessions {
|
||||||
|
if s.ID == sess.ID {
|
||||||
|
return s.Status == acp.SessionCrashed && s.LastError != nil && *s.LastError != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, 5*time.Second, 50*time.Millisecond, "session should be marked crashed with non-empty last_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionService_Create_MutuallyExclusiveInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
uid := uuid.New()
|
||||||
|
wsID := uuid.New()
|
||||||
|
akID := uuid.New()
|
||||||
|
|
||||||
|
svc := acp.NewSessionService(
|
||||||
|
&fakeAcpRepo{}, nil, nil, nil, nil, nil, nil,
|
||||||
|
acp.SessionServiceConfig{}, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
branch := "feat/x"
|
||||||
|
issueNum := 42
|
||||||
|
reqNum := 7
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in acp.CreateSessionInput
|
||||||
|
}{
|
||||||
|
{"branch+issue", acp.CreateSessionInput{Branch: &branch, IssueNumber: &issueNum}},
|
||||||
|
{"branch+requirement", acp.CreateSessionInput{Branch: &branch, RequirementNumber: &reqNum}},
|
||||||
|
{"issue+requirement", acp.CreateSessionInput{IssueNumber: &issueNum, RequirementNumber: &reqNum}},
|
||||||
|
{"all three", acp.CreateSessionInput{Branch: &branch, IssueNumber: &issueNum, RequirementNumber: &reqNum}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.in.WorkspaceID = wsID
|
||||||
|
tc.in.AgentKindID = akID
|
||||||
|
_, err := svc.Create(context.Background(), acp.Caller{UserID: uid}, tc.in)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "mutually exclusive")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
// ===== PermissionRequest(权限审批框架,测试桩) =====
|
||||||
func (f *fakeAcpRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
func (f *fakeAcpRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) {
|
||||||
return &acp.PermissionRequest{}, nil
|
return &acp.PermissionRequest{}, nil
|
||||||
|
|||||||
@@ -250,6 +250,25 @@ func (q *Queries) ListSessionsByUser(ctx context.Context, arg ListSessionsByUser
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markSessionFailedIfActive = `-- name: MarkSessionFailedIfActive :execrows
|
||||||
|
UPDATE acp_sessions
|
||||||
|
SET status = 'crashed', last_error = $2, ended_at = now()
|
||||||
|
WHERE id = $1 AND status IN ('starting', 'running')
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkSessionFailedIfActiveParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
LastError *string `json:"last_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) MarkSessionFailedIfActive(ctx context.Context, arg MarkSessionFailedIfActiveParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markSessionFailedIfActive, arg.ID, arg.LastError)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const releaseMainWorktree = `-- name: ReleaseMainWorktree :execrows
|
const releaseMainWorktree = `-- name: ReleaseMainWorktree :execrows
|
||||||
UPDATE workspaces SET active_main_session_id = NULL
|
UPDATE workspaces SET active_main_session_id = NULL
|
||||||
WHERE id = $1 AND active_main_session_id = $2
|
WHERE id = $1 AND active_main_session_id = $2
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ type Process struct {
|
|||||||
WorkspaceID uuid.UUID
|
WorkspaceID uuid.UUID
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
IsMain bool // 主 worktree 占用 → release 走不同路径
|
IsMain bool // 主 worktree 占用 → release 走不同路径
|
||||||
WorktreeID *uuid.UUID // 子 worktree 时填,用于 release
|
|
||||||
|
|
||||||
Cmd *exec.Cmd
|
Cmd *exec.Cmd
|
||||||
group procgrp.Group // 进程树句柄(整树终止),Spawn 时 Prepare+Adopt
|
group procgrp.Group // 进程树句柄(整树终止),Spawn 时 Prepare+Adopt
|
||||||
@@ -198,6 +197,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
}
|
}
|
||||||
if err := group.Adopt(cmd); err != nil {
|
if err := group.Adopt(cmd); err != nil {
|
||||||
_ = cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
|
_ = cmd.Wait() // 回收进程资源,避免 Unix 僵尸进程
|
||||||
return nil, fmt.Errorf("adopt process group: %w", err)
|
return nil, fmt.Errorf("adopt process group: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,19 +411,18 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
|||||||
s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error())
|
s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 走 workspace
|
// Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 按 holder
|
||||||
// service 的 holder 校验。当前 wtSvc.Release 接受 workspace.Caller,子 worktree
|
// 释放(holder = "session:<sid>",见 workspace.Caller.Holder),与启动 reaper
|
||||||
// 释放需要绕过 holder 检查(spec 期望 holder = "session:<sid>",与 user holder
|
// 一致,不需要知道 worktree ID。
|
||||||
// 不同),用 IsAdmin: true 强制释放。F4 reaper 会替换为更严格的 byHolder 调用。
|
|
||||||
if proc.IsMain {
|
if proc.IsMain {
|
||||||
if _, err := s.repo.ReleaseMainWorktree(ctx, proc.WorkspaceID, proc.SessionID); err != nil {
|
if _, err := s.repo.ReleaseMainWorktree(ctx, proc.WorkspaceID, proc.SessionID); err != nil {
|
||||||
s.log.Error("acp.on_exit.release_main",
|
s.log.Error("acp.on_exit.release_main",
|
||||||
"session_id", proc.SessionID, "err", err.Error())
|
"session_id", proc.SessionID, "err", err.Error())
|
||||||
}
|
}
|
||||||
} else if proc.WorktreeID != nil && s.wtSvc != nil {
|
} else if s.wtSvc != nil {
|
||||||
if _, err := s.wtSvc.Release(ctx, workspace.Caller{IsAdmin: true}, *proc.WorktreeID); err != nil {
|
if _, err := s.wtSvc.ReleaseByHolder(ctx, "session:"+proc.SessionID.String()); err != nil {
|
||||||
s.log.Error("acp.on_exit.release_worktree",
|
s.log.Error("acp.on_exit.release_worktree",
|
||||||
"session_id", proc.SessionID, "worktree_id", *proc.WorktreeID, "err", err.Error())
|
"session_id", proc.SessionID, "err", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ package acp_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,12 +26,13 @@ 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/notify"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newSupervisorForTest 构造一个绑定到 PG 真实 audit Recorder 的 Supervisor,
|
// newSupervisorForTest 构造一个绑定到 PG 真实 audit Recorder 的 Supervisor,
|
||||||
// 使用静音 logger + 默认 SupervisorConfig(5s SpawnTimeout 给 fake agent 留 buffer)。
|
// 使用静音 logger + 默认 SupervisorConfig(5s SpawnTimeout 给 fake agent 留 buffer)。
|
||||||
// withAudit=false 时 Recorder 为 nil,对应 onExit 跳过 audit 路径。
|
// withAudit=false 时 Recorder 为 nil,对应 onExit 跳过 audit 路径。
|
||||||
func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository, withAudit bool, mcpTokens mcp.TokenService) *acp.Supervisor {
|
func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository, withAudit bool, mcpTokens mcp.TokenService, wtSvc workspace.WorktreeService) *acp.Supervisor {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var rec audit.Recorder
|
var rec audit.Recorder
|
||||||
if withAudit {
|
if withAudit {
|
||||||
@@ -39,7 +41,7 @@ func newSupervisorForTest(t *testing.T, pool *pgxpool.Pool, repo acp.Repository,
|
|||||||
disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard())
|
disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard())
|
||||||
return acp.NewSupervisor(
|
return acp.NewSupervisor(
|
||||||
repo, rec, disp,
|
repo, rec, disp,
|
||||||
nil, nil, // wtSvc / wsRepo: tests 用 IsMain=true,走 repo.ReleaseMainWorktree
|
wtSvc, nil, // wtSvc: IsMain=false 测试注入 fake;wsRepo: 测试不走该路径
|
||||||
mcpTokens,
|
mcpTokens,
|
||||||
testEncryptor(t),
|
testEncryptor(t),
|
||||||
acp.SupervisorConfig{
|
acp.SupervisorConfig{
|
||||||
@@ -88,7 +90,7 @@ func TestSupervisor_HappyPath_Spawn_Handshake(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
sup := newSupervisorForTest(t, pool, repo, true, nil)
|
sup := newSupervisorForTest(t, pool, repo, true, nil, nil)
|
||||||
proc, err := sup.Spawn(ctx, sess, k, nil)
|
proc, err := sup.Spawn(ctx, sess, k, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, proc)
|
require.NotNil(t, proc)
|
||||||
@@ -217,7 +219,7 @@ func TestSupervisor_AgentCrashes(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
sup := newSupervisorForTest(t, pool, repo, true, nil)
|
sup := newSupervisorForTest(t, pool, repo, true, nil, nil)
|
||||||
proc, err := sup.Spawn(ctx, sess, k, nil)
|
proc, err := sup.Spawn(ctx, sess, k, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, proc)
|
require.NotNil(t, proc)
|
||||||
@@ -306,7 +308,7 @@ func TestSupervisor_OnExit_RevokesMCPToken(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
fakeTokens := &fakeSupMCPTokens{}
|
fakeTokens := &fakeSupMCPTokens{}
|
||||||
sup := newSupervisorForTest(t, pool, repo, true, fakeTokens)
|
sup := newSupervisorForTest(t, pool, repo, true, fakeTokens, nil)
|
||||||
proc, err := sup.Spawn(ctx, sess, k, nil)
|
proc, err := sup.Spawn(ctx, sess, k, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, proc)
|
require.NotNil(t, proc)
|
||||||
@@ -332,3 +334,86 @@ func TestSupervisor_OnExit_RevokesMCPToken(t *testing.T) {
|
|||||||
require.Len(t, fakeTokens.revoked, 1, "expected exactly one RevokeBySession call")
|
require.Len(t, fakeTokens.revoked, 1, "expected exactly one RevokeBySession call")
|
||||||
assert.Equal(t, sess.ID, fakeTokens.revoked[0], "RevokeBySession should receive the session ID")
|
assert.Equal(t, sess.ID, fakeTokens.revoked[0], "RevokeBySession should receive the session ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeWorktreeSvc 实现 workspace.WorktreeService,仅记录 ReleaseByHolder 收到的
|
||||||
|
// holder 字符串,用于验证 onExit 的子 worktree 释放路径。
|
||||||
|
type fakeWorktreeSvc struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
holders []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorktreeSvc) Create(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.CreateWorktreeInput) (*workspace.Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeWorktreeSvc) List(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]*workspace.Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeWorktreeSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeWorktreeSvc) Acquire(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeWorktreeSvc) Release(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Worktree, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeWorktreeSvc) ReleaseByHolder(_ context.Context, holder string) ([]*workspace.Worktree, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.holders = append(f.holders, holder)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdersSnapshot 拷贝当前已记录的 holder 列表(onExit 在 monitor goroutine 中调用)。
|
||||||
|
func (f *fakeWorktreeSvc) holdersSnapshot() []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]string(nil), f.holders...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSupervisor_OnExit_ReleasesSubWorktree 验证:IsMainWorktree=false 的 session
|
||||||
|
// 退出后,onExit 通过 wtSvc.ReleaseByHolder("session:<sid>") 释放子 worktree。
|
||||||
|
func TestSupervisor_OnExit_ReleasesSubWorktree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
repo, pool := setupRepo(t)
|
||||||
|
|
||||||
|
uid := mustInsertUser(t, ctx, pool, "sup-wt@local", false)
|
||||||
|
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
|
||||||
|
|
||||||
|
bin := fakeAgentBinary(t)
|
||||||
|
k, err := repo.CreateAgentKind(ctx, &acp.AgentKind{
|
||||||
|
ID: uuid.New(), Name: "fake-wt", DisplayName: "Fake",
|
||||||
|
BinaryPath: bin, Args: []string{},
|
||||||
|
Enabled: true, CreatedBy: uid,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sess, err := repo.InsertSession(ctx, &acp.Session{
|
||||||
|
ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid,
|
||||||
|
AgentKindID: k.ID, UserID: uid,
|
||||||
|
Branch: "feat-x", CwdPath: t.TempDir(), IsMainWorktree: false, Status: acp.SessionStarting,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fakeWt := &fakeWorktreeSvc{}
|
||||||
|
sup := newSupervisorForTest(t, pool, repo, true, nil, fakeWt)
|
||||||
|
proc, err := sup.Spawn(ctx, sess, k, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proc)
|
||||||
|
|
||||||
|
sup.Kill(ctx, sess.ID, 1*time.Second)
|
||||||
|
|
||||||
|
// onExit 在 monitor goroutine 中异步执行;轮询 fake 直到记录到 release 调用。
|
||||||
|
deadline := time.After(5 * time.Second)
|
||||||
|
for len(fakeWt.holdersSnapshot()) == 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("ReleaseByHolder never called after process exit")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holders := fakeWt.holdersSnapshot()
|
||||||
|
require.Len(t, holders, 1, "expected exactly one ReleaseByHolder call")
|
||||||
|
assert.Equal(t, "session:"+sess.ID.String(), holders[0])
|
||||||
|
}
|
||||||
|
|||||||
+41
-18
@@ -2,7 +2,6 @@ package mcp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -126,6 +125,27 @@ func resolveMountRefs(ctx context.Context, deps ServerDeps, c project.Caller,
|
|||||||
return refs, nil
|
return refs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopedConversation 解析 conversation_id、取会话(service 层含 ownership 校验)
|
||||||
|
// 并做项目 scope 校验:ProjectID 为 nil 的个人会话不受 project scope 约束。
|
||||||
|
// limit 控制随会话返回的最近消息条数,仅 get_conversation 需要,其余入口传 1。
|
||||||
|
func scopedConversation(ctx context.Context, deps ServerDeps, userID uuid.UUID,
|
||||||
|
conversationID string, limit int) (*chat.Conversation, []chat.Message, error) {
|
||||||
|
convID, err := uuid.Parse(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
|
||||||
|
}
|
||||||
|
cv, msgs, err := deps.Conversations.Get(ctx, userID, convID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if cv.ProjectID != nil {
|
||||||
|
if err := CheckProjectFromCtx(ctx, *cv.ProjectID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cv, msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ===== list_recent_messages =====
|
// ===== list_recent_messages =====
|
||||||
|
|
||||||
type listRecentMessagesIn struct {
|
type listRecentMessagesIn struct {
|
||||||
@@ -166,9 +186,9 @@ func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
return nil, listRecentMessagesOut{}, err
|
return nil, listRecentMessagesOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
convID, err := uuid.Parse(in.ConversationID)
|
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, listRecentMessagesOut{}, fmt.Errorf("invalid conversation_id: %w", err)
|
return nil, listRecentMessagesOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := in.Limit
|
limit := in.Limit
|
||||||
@@ -179,7 +199,7 @@ func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
limit = 200
|
limit = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := deps.Messages.ListMessages(ctx, c.UserID, convID, in.BeforeID, limit)
|
msgs, err := deps.Messages.ListMessages(ctx, c.UserID, cv.ID, in.BeforeID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, listRecentMessagesOut{}, err
|
return nil, listRecentMessagesOut{}, err
|
||||||
}
|
}
|
||||||
@@ -248,6 +268,13 @@ func registerListConversations(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
}
|
}
|
||||||
out := listConversationsOut{Conversations: make([]conversationDetail, 0, len(cvs))}
|
out := listConversationsOut{Conversations: make([]conversationDetail, 0, len(cvs))}
|
||||||
for i := range cvs {
|
for i := range cvs {
|
||||||
|
// 与 list_acp_sessions 一致:scope 外项目挂载的会话静默跳过;
|
||||||
|
// ProjectID 为 nil 的个人会话不受 project scope 约束。
|
||||||
|
if cvs[i].ProjectID != nil {
|
||||||
|
if err := CheckProjectFromCtx(ctx, *cvs[i].ProjectID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
out.Conversations = append(out.Conversations, conversationDetailFrom(&cvs[i]))
|
out.Conversations = append(out.Conversations, conversationDetailFrom(&cvs[i]))
|
||||||
}
|
}
|
||||||
return nil, out, nil
|
return nil, out, nil
|
||||||
@@ -276,10 +303,6 @@ func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, getConversationOut{}, err
|
return nil, getConversationOut{}, err
|
||||||
}
|
}
|
||||||
convID, err := uuid.Parse(in.ConversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, getConversationOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
|
|
||||||
}
|
|
||||||
limit := in.Limit
|
limit := in.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -287,7 +310,7 @@ func registerGetConversation(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
if limit > 200 {
|
if limit > 200 {
|
||||||
limit = 200
|
limit = 200
|
||||||
}
|
}
|
||||||
cv, msgs, err := deps.Conversations.Get(ctx, c.UserID, convID, limit)
|
cv, msgs, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, getConversationOut{}, err
|
return nil, getConversationOut{}, err
|
||||||
}
|
}
|
||||||
@@ -378,11 +401,11 @@ func registerUpdateConversationTitle(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, okOut{}, err
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
convID, err := uuid.Parse(in.ConversationID)
|
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
if err := deps.Conversations.UpdateTitle(ctx, c.UserID, convID, in.Title); err != nil {
|
if err := deps.Conversations.UpdateTitle(ctx, c.UserID, cv.ID, in.Title); err != nil {
|
||||||
return nil, okOut{}, err
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
return nil, okOut{OK: true}, nil
|
return nil, okOut{OK: true}, nil
|
||||||
@@ -406,11 +429,11 @@ func registerDeleteConversation(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, okOut{}, err
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
convID, err := uuid.Parse(in.ConversationID)
|
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
if err := deps.Conversations.SoftDelete(ctx, c.UserID, convID); err != nil {
|
if err := deps.Conversations.SoftDelete(ctx, c.UserID, cv.ID); err != nil {
|
||||||
return nil, okOut{}, err
|
return nil, okOut{}, err
|
||||||
}
|
}
|
||||||
return nil, okOut{OK: true}, nil
|
return nil, okOut{OK: true}, nil
|
||||||
@@ -440,11 +463,11 @@ func registerSendMessage(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, sendMessageOut{}, err
|
return nil, sendMessageOut{}, err
|
||||||
}
|
}
|
||||||
convID, err := uuid.Parse(in.ConversationID)
|
cv, _, err := scopedConversation(ctx, deps, c.UserID, in.ConversationID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, sendMessageOut{}, errs.Wrap(err, errs.CodeInvalidInput, "conversation_id parse")
|
return nil, sendMessageOut{}, err
|
||||||
}
|
}
|
||||||
userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, convID, in.Content, nil)
|
userMsgID, assistantMsgID, err := deps.Messages.Send(ctx, c.UserID, cv.ID, in.Content, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, sendMessageOut{}, err
|
return nil, sendMessageOut{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,9 +281,11 @@ func TestUpdateTitleAndDeleteConversation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSendMessage(t *testing.T) {
|
func TestSendMessage(t *testing.T) {
|
||||||
deps, _, msgSvc, _ := chatTestDeps()
|
deps, convSvc, msgSvc, _ := chatTestDeps()
|
||||||
|
cv := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
convSvc.items = []chat.Conversation{cv}
|
||||||
result, err := callTool(ctxWithAuth(Scope{}), deps, "send_message", map[string]any{
|
result, err := callTool(ctxWithAuth(Scope{}), deps, "send_message", map[string]any{
|
||||||
"conversation_id": uuid.New().String(), "content": "hello",
|
"conversation_id": cv.ID.String(), "content": "hello",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, result.IsError)
|
require.False(t, result.IsError)
|
||||||
@@ -294,6 +296,71 @@ func TestSendMessage(t *testing.T) {
|
|||||||
assert.Equal(t, "hello", msgSvc.sentContent)
|
assert.Equal(t, "hello", msgSvc.sentContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConversationTools_ScopeDenied 受限 token 直访 scope 外 project 挂载的会话 → 全部拒绝。
|
||||||
|
func TestConversationTools_ScopeDenied(t *testing.T) {
|
||||||
|
deps, convSvc, _, _ := chatTestDeps()
|
||||||
|
projB := uuid.New()
|
||||||
|
cvB := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &projB, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
convSvc.items = []chat.Conversation{cvB}
|
||||||
|
// token 限定到 testPID,cvB 挂载在 projB 上 → 拒绝
|
||||||
|
ctx := ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
tool string
|
||||||
|
args map[string]any
|
||||||
|
}{
|
||||||
|
{"list_recent_messages", map[string]any{"conversation_id": cvB.ID.String()}},
|
||||||
|
{"get_conversation", map[string]any{"conversation_id": cvB.ID.String()}},
|
||||||
|
{"update_conversation_title", map[string]any{"conversation_id": cvB.ID.String(), "title": "t"}},
|
||||||
|
{"delete_conversation", map[string]any{"conversation_id": cvB.ID.String()}},
|
||||||
|
{"send_message", map[string]any{"conversation_id": cvB.ID.String(), "content": "hi"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
result, err := callTool(ctx, deps, tc.tool, tc.args)
|
||||||
|
require.NoError(t, err, tc.tool)
|
||||||
|
assert.True(t, result.IsError, tc.tool)
|
||||||
|
}
|
||||||
|
// 被拒绝的 delete/update 不应实际生效
|
||||||
|
require.Len(t, convSvc.items, 1)
|
||||||
|
assert.Nil(t, convSvc.items[0].Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConversationTools_PersonalNotScoped ProjectID 为 nil 的个人会话不受 project scope 约束。
|
||||||
|
func TestConversationTools_PersonalNotScoped(t *testing.T) {
|
||||||
|
deps, convSvc, _, _ := chatTestDeps()
|
||||||
|
cv := chat.Conversation{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
convSvc.items = []chat.Conversation{cv}
|
||||||
|
|
||||||
|
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps,
|
||||||
|
"get_conversation", map[string]any{"conversation_id": cv.ID.String()})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.IsError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListConversations_ScopeFiltered 无 project filter 时静默剔除 scope 外项目挂载的会话。
|
||||||
|
func TestListConversations_ScopeFiltered(t *testing.T) {
|
||||||
|
deps, convSvc, _, _ := chatTestDeps()
|
||||||
|
pidA := testPID
|
||||||
|
projB := uuid.New()
|
||||||
|
convSvc.items = []chat.Conversation{
|
||||||
|
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &pidA, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||||
|
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), ProjectID: &projB, CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||||
|
{ID: uuid.New(), UserID: testUID, ModelID: uuid.New(), CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{pidA}}), deps,
|
||||||
|
"list_conversations", map[string]any{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, result.IsError)
|
||||||
|
var out listConversationsOut
|
||||||
|
unmarshalStructured(t, result, &out)
|
||||||
|
// projA 挂载 + 无挂载的保留,projB 挂载的被剔除
|
||||||
|
require.Len(t, out.Conversations, 2)
|
||||||
|
for _, cv := range out.Conversations {
|
||||||
|
assert.NotEqual(t, projB.String(), cv.ProjectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptTemplateCRUD(t *testing.T) {
|
func TestPromptTemplateCRUD(t *testing.T) {
|
||||||
deps, _, _, tplSvc := chatTestDeps()
|
deps, _, _, tplSvc := chatTestDeps()
|
||||||
|
|
||||||
|
|||||||
@@ -67,15 +67,37 @@ describe('AcpSessionCreateView', () => {
|
|||||||
it('mounts without error', async () => {
|
it('mounts without error', async () => {
|
||||||
const wrapper = mount(AcpSessionCreateView)
|
const wrapper = mount(AcpSessionCreateView)
|
||||||
await new Promise((r) => setTimeout(r, 20))
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
expect(wrapper.html()).toContain('New ACP Session')
|
expect(wrapper.html()).toContain('新建 ACP 会话')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefills requirement/issue number from query', async () => {
|
it('prefills issue number from query', async () => {
|
||||||
routeMock.query = { requirement_number: '7', issue_number: '42' }
|
routeMock.query = { issue_number: '42' }
|
||||||
const wrapper = mount(AcpSessionCreateView)
|
const wrapper = mount(AcpSessionCreateView)
|
||||||
await new Promise((r) => setTimeout(r, 20))
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
const inputs = wrapper.findAll('input[type="number"]')
|
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
|
||||||
expect((inputs[0].element as HTMLInputElement).value).toBe('42')
|
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
|
||||||
expect((inputs[1].element as HTMLInputElement).value).toBe('7')
|
expect((issueInput.element as HTMLInputElement).value).toBe('42')
|
||||||
|
expect((reqInput.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefills requirement number from query', async () => {
|
||||||
|
routeMock.query = { requirement_number: '7' }
|
||||||
|
const wrapper = mount(AcpSessionCreateView)
|
||||||
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
|
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
|
||||||
|
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
|
||||||
|
expect((issueInput.element as HTMLInputElement).value).toBe('')
|
||||||
|
expect((reqInput.element as HTMLInputElement).value).toBe('7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects submit when issue # and requirement # are both filled', async () => {
|
||||||
|
const wrapper = mount(AcpSessionCreateView)
|
||||||
|
await new Promise((r) => setTimeout(r, 20))
|
||||||
|
const issueInput = wrapper.find('[data-testid="issue-number-input"] input')
|
||||||
|
const reqInput = wrapper.find('[data-testid="requirement-number-input"] input')
|
||||||
|
await issueInput.setValue('42')
|
||||||
|
await reqInput.setValue('7')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
expect(wrapper.text()).toContain('最多只能填写一项')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NSelect,
|
||||||
|
NSpin,
|
||||||
|
} from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import { useAcpStore } from '@/stores/acp'
|
import { useAcpStore } from '@/stores/acp'
|
||||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||||
|
|
||||||
@@ -12,36 +23,62 @@ const wsStore = useWorkspacesStore()
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
agent_kind_id: '',
|
agent_kind_id: '',
|
||||||
branch: '',
|
branch: '',
|
||||||
issue_number: '' as string,
|
issue_number: null as number | null,
|
||||||
requirement_number: '' as string,
|
requirement_number: null as number | null,
|
||||||
initial_prompt: '',
|
initial_prompt: '',
|
||||||
})
|
})
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
const projectSlug = computed(() => route.params.slug as string)
|
||||||
|
const workspaceSlug = computed(() => route.params.wsSlug as string)
|
||||||
|
|
||||||
|
const agentKindOptions = computed(() =>
|
||||||
|
acpStore.enabledAgentKinds.map((k) => ({
|
||||||
|
label: k.display_name,
|
||||||
|
value: k.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const exclusiveCount = computed(
|
||||||
|
() =>
|
||||||
|
[form.value.branch, form.value.issue_number, form.value.requirement_number].filter(Boolean)
|
||||||
|
.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => !!form.value.agent_kind_id && exclusiveCount.value <= 1 && !submitting.value,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 来自需求详情页等入口的预填(?requirement_number= / ?issue_number=)。
|
|
||||||
if (typeof route.query.requirement_number === 'string') {
|
if (typeof route.query.requirement_number === 'string') {
|
||||||
form.value.requirement_number = route.query.requirement_number
|
form.value.requirement_number = Number(route.query.requirement_number)
|
||||||
}
|
}
|
||||||
if (typeof route.query.issue_number === 'string') {
|
if (typeof route.query.issue_number === 'string') {
|
||||||
form.value.issue_number = route.query.issue_number
|
form.value.issue_number = Number(route.query.issue_number)
|
||||||
}
|
}
|
||||||
await acpStore.listAgentKinds(false)
|
await acpStore.listAgentKinds(false)
|
||||||
if (wsStore.list.length === 0) {
|
if (wsStore.list.length === 0) {
|
||||||
await wsStore.fetchByProject(route.params.slug as string)
|
await wsStore.fetchByProject(projectSlug.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
submitting.value = true
|
|
||||||
try {
|
if (exclusiveCount.value > 1) {
|
||||||
const ws = wsStore.list.find((w) => w.slug === route.params.wsSlug)
|
errorMsg.value = 'Branch、Issue # 和 Requirement # 最多只能填写一项'
|
||||||
if (!ws) {
|
|
||||||
errorMsg.value = 'Workspace not found; please reload the page.'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ws = wsStore.list.find((w) => w.slug === workspaceSlug.value)
|
||||||
|
if (!ws) {
|
||||||
|
errorMsg.value = '未找到工作区,请刷新页面后重试'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
const req: {
|
const req: {
|
||||||
workspace_id: string
|
workspace_id: string
|
||||||
agent_kind_id: string
|
agent_kind_id: string
|
||||||
@@ -54,114 +91,116 @@ async function submit() {
|
|||||||
agent_kind_id: form.value.agent_kind_id,
|
agent_kind_id: form.value.agent_kind_id,
|
||||||
}
|
}
|
||||||
if (form.value.branch) req.branch = form.value.branch
|
if (form.value.branch) req.branch = form.value.branch
|
||||||
if (form.value.issue_number) req.issue_number = Number(form.value.issue_number)
|
if (form.value.issue_number != null) req.issue_number = form.value.issue_number
|
||||||
if (form.value.requirement_number)
|
if (form.value.requirement_number != null) req.requirement_number = form.value.requirement_number
|
||||||
req.requirement_number = Number(form.value.requirement_number)
|
|
||||||
if (form.value.initial_prompt) req.initial_prompt = form.value.initial_prompt
|
if (form.value.initial_prompt) req.initial_prompt = form.value.initial_prompt
|
||||||
|
|
||||||
const sess = await acpStore.createSession(req)
|
const sess = await acpStore.createSession(req)
|
||||||
router.push(
|
router.push({
|
||||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${sess.id}`,
|
name: 'acp-sessions-detail',
|
||||||
)
|
params: {
|
||||||
|
slug: projectSlug.value,
|
||||||
|
wsSlug: workspaceSlug.value,
|
||||||
|
sessionId: sess.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg.value = `Create failed: ${e instanceof Error ? e.message : 'unknown'}`
|
errorMsg.value = `创建失败:${e instanceof Error ? e.message : 'unknown'}`
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
router.push(
|
router.push({
|
||||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions`,
|
name: 'acp-sessions-list',
|
||||||
)
|
params: { slug: projectSlug.value, wsSlug: workspaceSlug.value },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<NSpin :show="acpStore.loading || wsStore.loading">
|
||||||
<div class="p-6 max-w-2xl">
|
<div class="p-6 max-w-2xl">
|
||||||
<h1 class="text-2xl font-semibold mb-4">New ACP Session</h1>
|
<h1 class="text-2xl font-semibold mb-4">
|
||||||
|
新建 ACP 会话
|
||||||
|
</h1>
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="submit">
|
<NForm @submit.prevent="submit">
|
||||||
<div>
|
<NFormItem
|
||||||
<label class="block text-sm font-medium mb-1">Agent Kind</label>
|
label="Agent Kind"
|
||||||
<select
|
|
||||||
v-model="form.agent_kind_id"
|
|
||||||
class="block w-full border rounded px-2 py-1 text-sm"
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="" disabled>-- choose --</option>
|
<NSelect
|
||||||
<option
|
v-model:value="form.agent_kind_id"
|
||||||
v-for="k in acpStore.enabledAgentKinds"
|
:options="agentKindOptions"
|
||||||
:key="k.id"
|
placeholder="请选择 Agent Kind"
|
||||||
:value="k.id"
|
/>
|
||||||
>
|
</NFormItem>
|
||||||
{{ k.display_name }}
|
|
||||||
</option>
|
<NFormItem label="Branch">
|
||||||
</select>
|
<NInput
|
||||||
|
v-model:value="form.branch"
|
||||||
|
placeholder="feat/x 或 main"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<NFormItem label="Issue #">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.issue_number"
|
||||||
|
:min="1"
|
||||||
|
placeholder="可选"
|
||||||
|
data-testid="issue-number-input"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Requirement #">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.requirement_number"
|
||||||
|
:min="1"
|
||||||
|
placeholder="可选"
|
||||||
|
data-testid="requirement-number-input"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<p class="text-xs text-gray-500 mb-4">
|
||||||
<label class="block text-sm font-medium mb-1">
|
Branch、Issue #、Requirement # 最多填写一项;全部留空将使用自动临时分支。
|
||||||
Branch <span class="text-xs text-gray-500">(optional, takes priority)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="form.branch"
|
|
||||||
class="block w-full border rounded px-2 py-1 text-sm font-mono"
|
|
||||||
placeholder="feat/x or main"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Issue # (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="form.issue_number"
|
|
||||||
type="number"
|
|
||||||
class="block w-full border rounded px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Requirement # (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="form.requirement_number"
|
|
||||||
type="number"
|
|
||||||
class="block w-full border rounded px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Branch resolution: explicit > issue > requirement > auto temp branch.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<NFormItem label="Initial Prompt">
|
||||||
<label class="block text-sm font-medium mb-1">Initial Prompt (optional)</label>
|
<NInput
|
||||||
<textarea
|
v-model:value="form.initial_prompt"
|
||||||
v-model="form.initial_prompt"
|
type="textarea"
|
||||||
rows="3"
|
:rows="3"
|
||||||
class="block w-full border rounded px-2 py-1 text-sm"
|
placeholder="会话启动后立即发送给 Agent 的提示词(可选)"
|
||||||
placeholder="Send this immediately after agent ready..."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</NFormItem>
|
||||||
|
|
||||||
<p v-if="errorMsg" class="text-sm text-red-600">{{ errorMsg }}</p>
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="cancel">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
attr-type="submit"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
data-testid="submit-btn"
|
||||||
|
>
|
||||||
|
创建会话
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<p
|
||||||
<button
|
v-if="errorMsg"
|
||||||
type="submit"
|
class="text-red-500 text-sm mt-2"
|
||||||
class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300"
|
|
||||||
:disabled="submitting || !form.agent_kind_id"
|
|
||||||
>
|
>
|
||||||
{{ submitting ? 'Creating...' : 'Create Session' }}
|
{{ errorMsg }}
|
||||||
</button>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1 rounded text-sm border hover:bg-gray-50"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</NSpin>
|
||||||
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user