This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+304 -5
View File
@@ -123,6 +123,14 @@ type fakeAcpRepo struct {
// when fn returns nil (simulating real transaction semantics).
txStaging []*acp.Session
inTx bool
// run-history dashboard: 记录入参 filter 供断言;返回预置结果。
rollupFilter acp.DashboardFilter
byDayFilter acp.DashboardFilter
rollupResult acp.SessionRollup
byDayResult []acp.SessionDayRow
timelineResult []acp.SessionTimelineBucket
timelineSID uuid.UUID
}
func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) error) error {
@@ -175,6 +183,43 @@ func (r *fakeAcpRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Sess
}
return nil, fmt.Errorf("not found")
}
func (r *fakeAcpRepo) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID {
return s, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeAcpRepo) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID &&
(s.Status == acp.SessionCrashed || s.Status == acp.SessionExited) {
return s, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeAcpRepo) ResetSessionForResume(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.ID == id {
s.Status = acp.SessionStarting
s.AgentSessionID = nil
s.PID = nil
s.ExitCode = nil
s.LastError = nil
s.EndedAt = nil
return nil
}
}
return fmt.Errorf("not found")
}
func (r *fakeAcpRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) {
for _, k := range r.kinds {
if k.ID == id {
@@ -252,6 +297,9 @@ func (r *fakeAcpRepo) ListAllSessions(_ context.Context, reqID *uuid.UUID) ([]*a
func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
return nil
}
func (r *fakeAcpRepo) UpdateSessionSandboxMode(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error {
return nil
}
@@ -276,6 +324,69 @@ func (r *fakeAcpRepo) ListEventsSince(_ context.Context, _ uuid.UUID, _ int64, _
return nil, nil
}
func (r *fakeAcpRepo) PurgeEventsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
func (r *fakeAcpRepo) InsertSessionUsage(_ context.Context, _ *acp.SessionUsageRecord) (bool, error) {
return true, nil
}
func (r *fakeAcpRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
return dp, dc, dt, dCost, nil
}
func (r *fakeAcpRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) {
return 0, nil
}
func (r *fakeAcpRepo) MarkSessionTerminatedReason(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]acp.ReaperSession, error) {
return nil, nil
}
func (r *fakeAcpRepo) ListSessionUsage(_ context.Context, _ uuid.UUID, _ int32) ([]*acp.SessionUsageRecord, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByUser(_ context.Context, _, _ time.Time) ([]acp.AcpUsageUserRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByModel(_ context.Context, _, _ time.Time) ([]acp.AcpUsageModelRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByDay(_ context.Context, _, _ time.Time) ([]acp.AcpUsageDayRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SessionRollup(_ context.Context, f acp.DashboardFilter) (acp.SessionRollup, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.rollupFilter = f
return r.rollupResult, nil
}
func (r *fakeAcpRepo) SessionsByDay(_ context.Context, f acp.DashboardFilter) ([]acp.SessionDayRow, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.byDayFilter = f
return r.byDayResult, nil
}
func (r *fakeAcpRepo) SessionTimeline(_ context.Context, sid uuid.UUID) ([]acp.SessionTimelineBucket, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.timelineSID = sid
return r.timelineResult, nil
}
func (r *fakeAcpRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { return t, nil }
func (r *fakeAcpRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, _ int, _ string, _ int) error {
return nil
}
func (r *fakeAcpRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, _ int) error { return nil }
func (r *fakeAcpRepo) IncrementTurnUpdateCount(_ context.Context, _ uuid.UUID, _ int) error {
return nil
}
func (r *fakeAcpRepo) GetLatestTurnBySession(_ context.Context, _ uuid.UUID) (*acp.Turn, error) {
return nil, nil
}
func (r *fakeAcpRepo) ListTurnsBySession(_ context.Context, _ uuid.UUID) ([]*acp.Turn, error) {
return nil, nil
}
func (r *fakeAcpRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) PurgeTurnsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
// fakeMCPTokenSvc satisfies mcp.TokenService for unit tests.
type fakeMCPTokenSvc struct {
@@ -382,7 +493,7 @@ func TestSessionService_Create_IssuesSystemToken(t *testing.T) {
)
svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil,
repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{
MaxActiveGlobal: 10,
MaxActivePerUser: 5,
@@ -441,7 +552,7 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) {
)
svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil,
repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{
MaxActiveGlobal: 10,
MaxActivePerUser: 5,
@@ -495,7 +606,7 @@ func TestSessionService_Create_SpawnFails_MarksSessionCrashed(t *testing.T) {
)
svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil,
repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{
MaxActiveGlobal: 10,
MaxActivePerUser: 5,
@@ -535,7 +646,7 @@ func TestSessionService_Create_MutuallyExclusiveInputs(t *testing.T) {
akID := uuid.New()
svc := acp.NewSessionService(
&fakeAcpRepo{}, nil, nil, nil, nil, nil, nil,
&fakeAcpRepo{}, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil,
)
@@ -598,7 +709,7 @@ func TestSessionService_List_RequirementFilter(t *testing.T) {
}
svc := acp.NewSessionService(
repo, nil, nil, nil, nil, nil, nil,
repo, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil,
)
@@ -616,3 +727,191 @@ func TestSessionService_List_RequirementFilter(t *testing.T) {
require.NoError(t, err)
require.Len(t, list, 2)
}
// TestSessionService_ResumeSession_ReacquiresMainWorktree 验证:在崩溃的 main-worktree
// session 上 ResumeSession 会重新占用 main worktree(CAS)、重新签发 system token、把
// session 复位为 starting,然后尝试 spawn。binary 为空致 spawn 失败,但前置的占用/签发/
// 复位都应已发生。
func TestSessionService_ResumeSession_ReacquiresMainWorktree(t *testing.T) {
t.Parallel()
uid := uuid.New()
wsID := uuid.New()
pid := uuid.New()
akID := uuid.New()
sessID := uuid.New()
exitCode := int32(1)
crashed := &acp.Session{
ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid,
Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true,
Status: acp.SessionCrashed, ExitCode: &exitCode,
}
repo := &fakeAcpRepo{
kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}},
sessions: []*acp.Session{crashed},
}
mcpTokens := &fakeMCPTokenSvc{}
sup := acp.NewSupervisor(
repo, nil, nil, nil, nil, mcpTokens, nil,
acp.SupervisorConfig{SpawnTimeout: 2 * time.Second, KillGrace: time.Second},
nil,
)
svc := acp.NewSessionService(
repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}},
nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil,
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"},
mcpTokens,
)
tr, ok := svc.(acp.TurnRunner)
require.True(t, ok, "sessionService must implement acp.TurnRunner")
// spawn 会失败(binary 空),ResumeSession 返回错误是预期的。
_, _ = tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID)
// 但 token 应已重新签发(占用 + InTx 复位在 spawn 之前)。
mcpTokens.mu.Lock()
issuedCount := len(mcpTokens.issued)
mcpTokens.mu.Unlock()
require.GreaterOrEqual(t, issuedCount, 1, "resume should re-issue a system token")
assert.Equal(t, sessID, mcpTokens.issued[0].ACPSessionID)
}
// TestSessionService_ResumeSession_ActiveSessionNoOp 验证:对已活跃 session 调用
// ResumeSession 直接返回该 session,不重新占用/签发。
func TestSessionService_ResumeSession_ActiveSessionNoOp(t *testing.T) {
t.Parallel()
uid := uuid.New()
wsID := uuid.New()
pid := uuid.New()
akID := uuid.New()
sessID := uuid.New()
active := &acp.Session{
ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid,
Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true, Status: acp.SessionRunning,
}
repo := &fakeAcpRepo{
kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}},
sessions: []*acp.Session{active},
}
mcpTokens := &fakeMCPTokenSvc{}
sup := acp.NewSupervisor(repo, nil, nil, nil, nil, mcpTokens, nil,
acp.SupervisorConfig{SpawnTimeout: time.Second, KillGrace: time.Second}, nil)
svc := acp.NewSessionService(repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}},
nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil,
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"},
mcpTokens)
tr := svc.(acp.TurnRunner)
out, err := tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, sessID, out.ID)
mcpTokens.mu.Lock()
defer mcpTokens.mu.Unlock()
assert.Len(t, mcpTokens.issued, 0, "active session resume must not re-issue tokens")
}
// ===== run-history dashboard =====
func newDashboardSvc(repo *fakeAcpRepo) acp.SessionService {
return acp.NewSessionService(repo, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil)
}
func TestDashboard_NonAdmin_ScopedToCaller(t *testing.T) {
t.Parallel()
uid := uuid.New()
repo := &fakeAcpRepo{
rollupResult: acp.SessionRollup{Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5, TotalTokens: 900},
byDayResult: []acp.SessionDayRow{{Day: time.Now().UTC(), Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5}},
}
svc := newDashboardSvc(repo)
res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uid}, acp.DashboardInput{})
require.NoError(t, err)
require.NotNil(t, res)
// 非 admin 强制按 caller scope。
require.NotNil(t, repo.rollupFilter.UserID)
assert.Equal(t, uid, *repo.rollupFilter.UserID)
require.NotNil(t, repo.byDayFilter.UserID)
assert.Equal(t, uid, *repo.byDayFilter.UserID)
// 默认时间窗为半开且 from 早于 to。
assert.True(t, repo.rollupFilter.From.Before(repo.rollupFilter.To))
// rollup 透传 + 成功率计算。
assert.Equal(t, int64(4), res.Rollup.Total)
assert.InDelta(t, 0.75, res.Rollup.SuccessRate(), 1e-9)
assert.InDelta(t, 1.5, res.Rollup.TotalCostUSD, 1e-9)
assert.Len(t, res.ByDay, 1)
}
func TestDashboard_Admin_NoUserScope_WithFilters(t *testing.T) {
t.Parallel()
pid := uuid.New()
reqID := uuid.New()
from := time.Now().Add(-48 * time.Hour).UTC()
to := time.Now().UTC()
repo := &fakeAcpRepo{rollupResult: acp.SessionRollup{Total: 10, Succeeded: 10}}
svc := newDashboardSvc(repo)
res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true},
acp.DashboardInput{ProjectID: &pid, RequirementID: &reqID, From: from, To: to})
require.NoError(t, err)
// admin → 无 user scope;project/requirement/时间窗透传。
assert.Nil(t, repo.rollupFilter.UserID)
require.NotNil(t, repo.rollupFilter.ProjectID)
assert.Equal(t, pid, *repo.rollupFilter.ProjectID)
require.NotNil(t, repo.rollupFilter.RequirementID)
assert.Equal(t, reqID, *repo.rollupFilter.RequirementID)
assert.Equal(t, from, repo.rollupFilter.From)
assert.Equal(t, to, repo.rollupFilter.To)
assert.InDelta(t, 1.0, res.Rollup.SuccessRate(), 1e-9)
}
func TestDashboard_InvalidWindow(t *testing.T) {
t.Parallel()
to := time.Now().UTC()
from := to.Add(time.Hour) // from after to
svc := newDashboardSvc(&fakeAcpRepo{})
_, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New()},
acp.DashboardInput{From: from, To: to})
require.Error(t, err)
}
func TestTimeline_OwnerAccess(t *testing.T) {
t.Parallel()
uid := uuid.New()
sid := uuid.New()
buckets := []acp.SessionTimelineBucket{
{Direction: "out", RPCKind: "request", Method: "session/prompt", EventCount: 2},
{Direction: "in", RPCKind: "notification", Method: "session/update", EventCount: 5},
}
repo := &fakeAcpRepo{timelineResult: buckets}
repo.sessions = []*acp.Session{{ID: sid, UserID: uid}}
svc := newDashboardSvc(repo)
got, err := svc.Timeline(context.Background(), acp.Caller{UserID: uid}, sid)
require.NoError(t, err)
assert.Equal(t, sid, repo.timelineSID)
require.Len(t, got, 2)
assert.Equal(t, "session/prompt", got[0].Method)
}
func TestTimeline_NonOwner_NotFound(t *testing.T) {
t.Parallel()
sid := uuid.New()
repo := &fakeAcpRepo{}
repo.sessions = []*acp.Session{{ID: sid, UserID: uuid.New()}}
svc := newDashboardSvc(repo)
_, err := svc.Timeline(context.Background(), acp.Caller{UserID: uuid.New()}, sid)
require.Error(t, err) // Get 拒绝 → 不泄漏会话
}