You've already forked agentic-coding-workflow
413 lines
10 KiB
Go
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
|
|
}
|