Files
Agent-System/internal/app/app.go
T
weichenandCursor e4b4e0dde8 feat(api): expose the trace index, reopen, and recheck routes
Use the record tables when migration 00059 is present, and hide statements from documents the caller cannot see.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 15:35:07 +00:00

484 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/artifactvault"
"chat-agent-system/internal/auth"
"chat-agent-system/internal/authz"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/events"
"chat-agent-system/internal/freedoc"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/kb"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/observability"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/storage"
"chat-agent-system/internal/sysadmin"
"chat-agent-system/internal/taskrun"
"chat-agent-system/internal/traceimpact"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workspace"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultReminderPollInterval = 15 * time.Minute
// Runtime is the unique composition root. cmd/server, tests and workers
// construct it once; HTTP adapters must not re-wire domain services.
type Runtime struct {
Cfg *config.Config
Biz *bizdev.Service
Engine *coordinator.CoordinatorEngine
Auth *auth.Service
PW *projectworkflow.Service
TaskRuns *taskrun.Manager
Authorizer *authz.Authorizer
Authz *authz.Service
WFDef *workflowdef.Service
Inst *projectinstance.Service
Notify *notification.Service
Resolver *workspace.Resolver
WSCap *workspace.Capability
Commands *taskrun.Commands
Rec intent.Recognizer
Stageflow *stageflow.UseCase
DocNotify *docnotify.Subscriber
ChatLog chatflow.ChatLog
Assembler chatflow.ContextAssembler
Docflow *docflow.Commands
IntentPlans *intentflow.Plans
Pool *pgxpool.Pool
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Trace *traceimpact.Service
Ctx *ctxmgr.ContextManager
CAS *storage.CAS
FreeDoc *freedoc.Service
KB *kb.Client
SysAdmin *sysadmin.Service
mu sync.Mutex
running bool
subscribers int
starts int
notifyBound bool
hookBound bool
reminderCancel context.CancelFunc
}
func New(cfg *config.Config) (*Runtime, error) {
return Assemble(cfg, nil)
}
// Assemble wires domain services. svc may be injected by tests.
func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
if cfg == nil {
cfg, _ = config.Load("")
}
if cfg != nil {
observability.InitLogger(cfg.Env)
}
if svc == nil {
var err error
svc, err = bizdev.NewService(cfg)
if err != nil {
return nil, err
}
}
rec := intent.NewRecognizer()
backendName := ""
if cfg != nil {
backendName = cfg.AgentBackend
}
dialogueBackend := agent.NewBackend(backendName, cfg)
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(nil, dialogueBackend, cfg.DataDir)
parts := bizdev.Snapshot(svc)
if ctxmgr.SemanticSummaryShadowEnabled() && parts.Ctx != nil && backendName == "pi-agent" {
model := ""
dataDir := ""
if cfg != nil {
model = cfg.PiAgentModel
dataDir = cfg.DataDir
}
parts.Ctx.SetSemanticSummarizer(&PiSemanticSummarizer{Backend: dialogueBackend, Model: model, DataDir: dataDir})
}
if docrevision.PIChangeReportNarrativeEnabled() && parts.Rev != nil && backendName == "pi-agent" {
model, dataDir := "", ""
if cfg != nil {
model = cfg.PiAgentModel
dataDir = cfg.DataDir
}
narr := &PiChangeReportNarrator{Backend: dialogueBackend, Model: model, DataDir: dataDir}
parts.Rev.NarrativeFetcher = narr.Fetch
}
if intentflow.CurrentFeatureFlags().U9Segment && backendName == "pi-agent" {
model, dataDir := "", ""
if cfg != nil {
model = cfg.PiAgentModel
dataDir = cfg.DataDir
}
intentflow.SetLLMSegmentProposer(&PiSegmentProposer{Backend: dialogueBackend, Model: model, DataDir: dataDir})
}
pool := parts.Pool
stores, storeErr := auth.NewStores(context.Background(), pool, cfg)
if storeErr != nil {
return nil, fmt.Errorf("auth store: %w", storeErr)
}
authSvc := auth.NewService(stores.Auth, stores.Sessions, cfg)
pwSvc := parts.PW
if pwSvc == nil {
pwSvc = projectworkflow.NewService(projectworkflow.NewStore(nil), nil)
}
authzSvc := authz.NewService(pool)
wfDefSvc := workflowdef.NewService(pool, parts.CAS)
instStore := projectinstance.NewStore(pool)
var instSvc *projectinstance.Service
if wfDefSvc.Available() {
docChecker := projectinstance.NewDocGovChecker(parts.Gov)
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker)
if pool != nil {
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
}
if parts.Gov != nil {
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
}
}
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
if pool != nil {
taskMgr.SetStore(taskrun.NewPGStore(pool))
}
if instSvc != nil && parts.Gov != nil {
instSvc.SetDocTaskBusyChecker(&docTaskBusyCheckerAdapter{gov: parts.Gov, taskRuns: taskMgr})
}
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
cmds := &taskrun.Commands{
Ctx: parts.Ctx,
PW: pwSvc,
Env: cfg.Env,
StorageIdentity: "env:" + cfg.Env,
}
if pool != nil {
cmds.UoW = uow.NewPG(pool)
cmds.Fav = &artifactvault.Ingest{Env: cfg.Env}
}
taskMgr.SetCommands(cmds)
stages := stageflow.New(svc)
coordEngine.SetStageTaskRunner(stages)
coordEngine.SetContextServices(parts.Ctx, parts.KB)
if instSvc != nil && instSvc.Available() {
coordEngine.SetInstanceService(instSvc)
}
traceSvc := traceimpact.Open(pool)
traceSvc.SetStageReopener(traceStage{inst: instSvc})
traceSvc.SetDocUnlocker(traceUnlock{gov: parts.Gov})
if instSvc != nil {
instSvc.SetRecheckGate(traceRecheck{svc: traceSvc})
}
coordEngine.SetImpactActions(traceChat{svc: traceSvc})
if pool != nil {
access := authz.NewAuthorizer(pool)
coordEngine.SetProjectAccess(func(ctx context.Context, projectID, userID int64) error {
return access.RequireProjectAccess(ctx, projectID, userID)
})
}
if parts.Rev != nil {
parts.Rev.CoverageResolver = func(ctx context.Context, docID string) string {
projectID := projectIDFromControlledDoc(docID)
if projectID == 0 {
return traceimpact.ImpactIndexPending
}
return traceSvc.CoverageForDoc(ctx, projectID, docID)
}
}
rt := &Runtime{
Cfg: cfg,
Biz: svc,
Engine: coordEngine,
Auth: authSvc,
PW: pwSvc,
TaskRuns: taskMgr,
Authorizer: authz.NewAuthorizer(pool),
Authz: authzSvc,
WFDef: wfDefSvc,
Inst: instSvc,
Notify: notification.NewService(pool),
Resolver: workspace.NewResolver(cfg.DataDir, pool),
WSCap: wsCap,
Commands: cmds,
Rec: rec,
Stageflow: stages,
Pool: pool,
Gov: parts.Gov,
Rev: parts.Rev,
Trace: traceSvc,
Ctx: parts.Ctx,
CAS: parts.CAS,
FreeDoc: parts.FreeDoc,
KB: parts.KB,
}
rt.SysAdmin = sysadmin.NewService(cfg, sysadmin.Options{
Pool: pool,
Status: svc,
KB: parts.KB,
TaskStoreReady: func(ctx context.Context) bool {
return taskrun.TablesReady(ctx, pool)
},
FeatureValues: sysadminFeatureValues(cfg),
})
if instSvc != nil {
instSvc.SetAuthorOnlyPolicySource(&sysadminAuthorOnlyPolicyAdapter{svc: rt.SysAdmin})
svc.SetInstanceDutySource(&projectInstanceDutyAdapter{inst: instSvc})
}
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: parts.Gov}
rt.DocNotify.SkipWrite = func(ctx context.Context) bool {
return rt.WriterLive(ctx, events.ConsumerNotify)
}
envName := ""
if cfg != nil {
envName = cfg.Env
}
rt.Docflow = &docflow.Commands{
Gov: parts.Gov, Rev: parts.Rev, Auth: docflow.DutyAuthorizer(),
Environment: envName,
}
if pool != nil {
rt.Docflow.UoW = uow.NewPG(pool)
rt.Docflow.Events = events.PGSink{}
livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != ""
taskOwner := NewTaskRunAdapter()
taskOwner.Starter = LiveTaskStarter(taskMgr, livePI)
// BuildOwnerMux registers U10–U12 dry-run stubs only when independent flags are on.
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.BuildOwnerMux(
intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
taskOwner,
),
Events: events.PGSink{},
})
}
svc.SetAgentMaterializer(rt.Docflow)
rt.bindDocumentNotify()
rt.bindWorkflowApprovalHook()
return rt, nil
}
func (r *Runtime) bindDocumentNotify() {
if r == nil || r.notifyBound {
return
}
// Durable notify is live; do not assign OnOutbox in production.
r.notifyBound = true
}
func (r *Runtime) bindWorkflowApprovalHook() {
if r == nil || r.hookBound {
return
}
// Durable workflow_advance is live; do not register AddOnApprovedHook in production.
r.hookBound = true
}
// sysadminFeatureValues 在组合根解析各业务包公开 flag 的生效值,供 sysadmin 聚合层
// 只读消费;sysadmin 不 import 业务包。
func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
return func() map[string]string {
iflags := intent.CurrentFeatureFlags()
pflags := intentflow.CurrentFeatureFlags()
sflags := ctxmgr.CurrentSummaryFlags()
values := map[string]string{
"INTENT_V2_SHADOW": strconv.FormatBool(iflags.IntentV2Shadow),
"INTENT_V2_DECISION": strconv.FormatBool(iflags.IntentV2Decision),
"INTENT_V2_DECISION_TIER": string(iflags.V2DecisionTier),
"WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled),
"MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled),
"MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier),
"MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment),
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
"MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl),
"SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow),
"SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace),
"PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()),
}
if cfg != nil {
values["AGENT_BACKEND"] = cfg.AgentBackend
values["MAX_CONCURRENT_AGENTS"] = strconv.Itoa(cfg.MaxConcurrentAgents)
values["PI_AGENT_RUNNER_URL"] = cfg.PiAgentRunnerURL
values["ALLOW_IN_MEMORY"] = strconv.FormatBool(cfg.AllowInMemory)
values["AUTH_REGISTRATION_ENABLED"] = strconv.FormatBool(cfg.AuthRegistrationEnabled)
values["AUTH_REQUIRE_APPROVAL"] = strconv.FormatBool(cfg.AuthRequireApproval)
values["FAV_PUBLISHER_ENABLED"] = strconv.FormatBool(cfg.FAVPublisherEnabled)
values["FAV_DEGRADED_READ"] = strconv.FormatBool(cfg.FAVDegradedRead)
}
return values
}
}
// WriterLive reports whether the durable consumer already owns the visible write.
func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool {
if r == nil {
return false
}
env := ""
if r.Cfg != nil {
env = r.Cfg.Env
}
return events.WriterLive(ctx, r.Pool, env, consumer)
}
func (r *Runtime) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) {
if r.WriterLive(ctx, events.ConsumerWorkflowAdvance) {
return
}
if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || r.Inst == nil {
return
}
result, err := r.Inst.HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey)
if err != nil || result == nil || !result.StageReady {
return
}
if r.Notify == nil || !r.Notify.Available() {
return
}
if r.PW == nil {
return
}
project, err := r.PW.GetProject(ctx, doc.ProjectID, 0)
if err != nil || project == nil || project.CreatorUserID <= 0 {
return
}
_, _ = r.Notify.EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{
ProjectID: doc.ProjectID,
WorkflowInstanceID: result.InstanceID,
StageInstanceID: result.StageInstanceID,
StageKey: result.StageKey,
StageName: result.StageName,
ConfigRevision: result.ConfigRevision,
RequiredDocCount: result.RequiredDocCount,
ApprovedDocCount: result.ApprovedDocCount,
})
}
// Start launches background workers. Repeat calls on a running runtime are no-ops.
func (r *Runtime) Start(_ context.Context) error {
if r == nil {
return fmt.Errorf("app: nil runtime")
}
r.mu.Lock()
defer r.mu.Unlock()
if r.running {
return nil
}
r.bindDocumentNotify()
r.bindWorkflowApprovalHook()
ctx, cancel := context.WithCancel(context.Background())
r.reminderCancel = cancel
go r.runReminderLoop(ctx)
r.running = true
r.starts++
if r.subscribers == 0 {
r.subscribers = 1
}
return nil
}
// Stop cancels workers. A later Start recreates the reminder loop without
// double-registering document subscribers.
func (r *Runtime) Stop(_ context.Context) error {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.running {
return nil
}
if r.reminderCancel != nil {
r.reminderCancel()
r.reminderCancel = nil
}
r.running = false
return nil
}
func (r *Runtime) SubscriberCount() int {
if r == nil {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
return r.subscribers
}
func (r *Runtime) StartCount() int {
if r == nil {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
return r.starts
}
func (r *Runtime) Running() bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.running
}
func (r *Runtime) runReminderLoop(ctx context.Context) {
r.runStageAdvanceReminders(ctx)
ticker := time.NewTicker(defaultReminderPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.runStageAdvanceReminders(ctx)
}
}
}
func (r *Runtime) runStageAdvanceReminders(ctx context.Context) {
if r.Notify == nil || !r.Notify.Available() {
return
}
n, err := r.Notify.ProcessStageAdvanceReminders(ctx)
if err != nil {
slog.Warn("stage advance reminder scan failed", "error", err)
return
}
if n > 0 {
slog.Info("stage advance 24h reminders sent", "count", n)
}
}