Files
2026-06-22 08:55:57 +08:00

413 lines
10 KiB
Go

package orchestrator
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// ===== fakeRepo: 内存版 orchestrator.Repository =====
type fakeRepo struct {
mu sync.Mutex
runs map[uuid.UUID]*Run
steps map[uuid.UUID]*Step
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{runs: map[uuid.UUID]*Run{}, steps: map[uuid.UUID]*Step{}}
}
func cloneRun(r *Run) *Run { c := *r; return &c }
func cloneStep(s *Step) *Step { c := *s; return &c }
func (r *fakeRepo) InsertRun(_ context.Context, run *Run) (*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
if run.ID == uuid.Nil {
run.ID = uuid.New()
}
run.CreatedAt = time.Now()
run.UpdatedAt = run.CreatedAt
r.runs[run.ID] = cloneRun(run)
return cloneRun(run), nil
}
func (r *fakeRepo) GetRun(_ context.Context, id uuid.UUID) (*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
run, ok := r.runs[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "run not found")
}
return cloneRun(run), nil
}
func (r *fakeRepo) GetActiveRunByRequirement(_ context.Context, reqID uuid.UUID) (*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, run := range r.runs {
if run.RequirementID == reqID && run.Status.IsActive() || (run.RequirementID == reqID && run.Status == RunPaused) {
return cloneRun(run), nil
}
}
return nil, errs.New(errs.CodeNotFound, "no active run")
}
func (r *fakeRepo) ListRuns(_ context.Context, f RunFilter) ([]*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := []*Run{}
for _, run := range r.runs {
if f.RequirementID != nil && run.RequirementID != *f.RequirementID {
continue
}
if f.Status != "" && run.Status != f.Status {
continue
}
out = append(out, cloneRun(run))
}
return out, nil
}
func (r *fakeRepo) UpdateRunStatus(_ context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
run, ok := r.runs[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "run not found")
}
run.Status = status
run.LastError = lastErr
run.UpdatedAt = time.Now()
return cloneRun(run), nil
}
func (r *fakeRepo) UpdateRunPhase(_ context.Context, id uuid.UUID, phase project.Phase) (*Run, error) {
r.mu.Lock()
defer r.mu.Unlock()
run, ok := r.runs[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "run not found")
}
run.CurrentPhase = phase
run.UpdatedAt = time.Now()
return cloneRun(run), nil
}
func (r *fakeRepo) InsertStep(_ context.Context, s *Step) (*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
if s.ID == uuid.Nil {
s.ID = uuid.New()
}
s.CreatedAt = time.Now()
s.UpdatedAt = s.CreatedAt
r.steps[s.ID] = cloneStep(s)
return cloneStep(s), nil
}
func (r *fakeRepo) GetStep(_ context.Context, id uuid.UUID) (*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.steps[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "step not found")
}
return cloneStep(s), nil
}
func (r *fakeRepo) GetStepBySession(_ context.Context, sessionID uuid.UUID) (*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.steps {
if s.ACPSessionID != nil && *s.ACPSessionID == sessionID {
return cloneStep(s), nil
}
}
return nil, errs.New(errs.CodeNotFound, "step not found")
}
func (r *fakeRepo) ListStepsByRun(_ context.Context, runID uuid.UUID) ([]*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := []*Step{}
for _, s := range r.steps {
if s.RunID == runID {
out = append(out, cloneStep(s))
}
}
return out, nil
}
func (r *fakeRepo) UpdateStepStatus(_ context.Context, s *Step) (*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.steps[s.ID]
if !ok {
return nil, errs.New(errs.CodeNotFound, "step not found")
}
cur.Status = s.Status
cur.ACPSessionID = s.ACPSessionID
cur.StopReason = s.StopReason
cur.GateResult = s.GateResult
cur.LastError = s.LastError
cur.JobID = s.JobID
cur.UpdatedAt = time.Now()
return cloneStep(cur), nil
}
func (r *fakeRepo) IncrementStepAttempt(_ context.Context, id uuid.UUID) (*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.steps[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "step not found")
}
s.Attempt++
s.UpdatedAt = time.Now()
return cloneStep(s), nil
}
func (r *fakeRepo) CountActiveChildSteps(_ context.Context, parentStepID uuid.UUID) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
var n int64
for _, s := range r.steps {
if s.ParentStepID != nil && *s.ParentStepID == parentStepID {
switch s.Status {
case StepPending, StepSpawning, StepRunning, StepAwaitingGate:
n++
}
}
}
return n, nil
}
func (r *fakeRepo) ResetStuckStepsOnRestart(_ context.Context) ([]*Step, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := []*Step{}
for _, s := range r.steps {
run, ok := r.runs[s.RunID]
if !ok {
continue
}
active := run.Status.IsActive() || run.Status == RunPaused
switch s.Status {
case StepSpawning, StepRunning, StepAwaitingGate:
if active {
s.Status = StepPending
out = append(out, cloneStep(s))
}
}
}
return out, nil
}
// fakeRepo 不支持真正的事务:InTx 直接调用 fn(同一实例),WithTx 返回自身。
func (r *fakeRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
return fn(ctx, nil)
}
func (r *fakeRepo) WithTx(_ pgx.Tx) Repository { return r }
// ===== fakePM: 内存版 PMAccess =====
type fakePM struct {
mu sync.Mutex
proj *project.Project
req *project.Requirement
artifacts []*project.Artifact
phaseWrites []project.Phase
phaseErr error
}
func (p *fakePM) GetProjectByID(_ context.Context, _ uuid.UUID) (*project.Project, error) {
return p.proj, nil
}
func (p *fakePM) GetProjectBySlug(_ context.Context, _ string) (*project.Project, error) {
return p.proj, nil
}
func (p *fakePM) GetRequirementByID(_ context.Context, _ uuid.UUID) (*project.Requirement, error) {
return p.req, nil
}
func (p *fakePM) GetRequirementByNumber(_ context.Context, _ uuid.UUID, _ int) (*project.Requirement, error) {
return p.req, nil
}
func (p *fakePM) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) {
return p.artifacts, nil
}
func (p *fakePM) ChangeRequirementPhase(_ context.Context, _ uuid.UUID, _ bool, _ string, _ int, to project.Phase) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.phaseErr != nil {
return p.phaseErr
}
p.phaseWrites = append(p.phaseWrites, to)
if p.req != nil {
p.req.Phase = to
}
return nil
}
// ===== fakeTurnRunner: acp.TurnRunner =====
type fakeTurnRunner struct {
stopReason string
err error
resumeErr error
created *acp.Session
resumed *acp.Session
calls int
}
func (f *fakeTurnRunner) SendPromptAndWait(_ context.Context, sessionID uuid.UUID, _ string) (string, error) {
f.calls++
return f.stopReason, f.err
}
func (f *fakeTurnRunner) ResumeSession(_ context.Context, _ acp.Caller, sessionID uuid.UUID) (*acp.Session, error) {
if f.resumeErr != nil {
return nil, f.resumeErr
}
return &acp.Session{ID: sessionID, Status: acp.SessionStarting}, nil
}
// ===== fakeACPSessions: orchestrator.ACPSessions =====
type fakeACPSessions struct {
mu sync.Mutex
created []acp.CreateSessionInput
nextID uuid.UUID
createErr error
}
func (f *fakeACPSessions) Create(_ context.Context, _ acp.Caller, in acp.CreateSessionInput) (*acp.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.createErr != nil {
return nil, f.createErr
}
f.created = append(f.created, in)
id := f.nextID
if id == uuid.Nil {
id = uuid.New()
}
return &acp.Session{ID: id, Status: acp.SessionStarting, OrchestratorStepID: in.OrchestratorStepID}, nil
}
// ===== fakeLookup: orchestrator.ACPSessionLookup =====
type fakeLookup struct {
crashed map[uuid.UUID]*acp.Session // stepID → crashed session
live map[uuid.UUID]*acp.Session // stepID → live session
}
func newFakeLookup() *fakeLookup {
return &fakeLookup{crashed: map[uuid.UUID]*acp.Session{}, live: map[uuid.UUID]*acp.Session{}}
}
func (f *fakeLookup) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
if s, ok := f.live[stepID]; ok {
return s, nil
}
return nil, errs.New(errs.CodeNotFound, "no session")
}
func (f *fakeLookup) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
if s, ok := f.crashed[stepID]; ok {
return s, nil
}
return nil, errs.New(errs.CodeNotFound, "no crashed session")
}
// ===== fakeJobs: orchestrator.JobEnqueuer =====
type fakeJobs struct {
mu sync.Mutex
enqueued []stepPayload
}
func (j *fakeJobs) Enqueue(_ context.Context, _ jobs.JobType, payload json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) {
j.mu.Lock()
defer j.mu.Unlock()
var p stepPayload
_ = json.Unmarshal(payload, &p)
j.enqueued = append(j.enqueued, p)
return &jobs.Job{ID: uuid.New()}, nil
}
func (j *fakeJobs) count() int {
j.mu.Lock()
defer j.mu.Unlock()
return len(j.enqueued)
}
// ===== fakeGate: orchestrator.PhaseGate =====
type fakeGate struct {
result GateResult
err error
}
func (g fakeGate) Evaluate(_ context.Context, _ *Run, _ *Step, _ string) (GateResult, error) {
return g.result, g.err
}
// ===== fakeAudit: orchestrator.AuditRecorder =====
type fakeAudit struct {
mu sync.Mutex
actions []string
}
func (a *fakeAudit) Record(_ context.Context, _ uuid.UUID, action, _ string, _ map[string]any) {
a.mu.Lock()
defer a.mu.Unlock()
a.actions = append(a.actions, action)
}
func (a *fakeAudit) has(action string) bool {
a.mu.Lock()
defer a.mu.Unlock()
for _, x := range a.actions {
if x == action {
return true
}
}
return false
}
// ===== fakeSessTerm: orchestrator.sessionTerminator =====
type fakeSessTerm struct {
mu sync.Mutex
terminated []uuid.UUID
}
func (s *fakeSessTerm) TerminateSession(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
s.terminated = append(s.terminated, sessionID)
return nil
}
// ===== fakeACL: orchestrator.ProjectACL =====
type fakeACL struct {
allow bool
read bool
}
func (a fakeACL) CanRead(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) {
return a.allow || a.read, nil
}
func (a fakeACL) CanWrite(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) {
return a.allow, nil
}