package coordinator import ( "errors" "chat-agent-system/internal/intent" ) var ( ErrNilContext = errors.New("coordinator: nil user project context") ErrStaleContext = errors.New("coordinator: stale context version") ErrInvalidUpdate = errors.New("coordinator: empty context update") ) // ContextUpdate is the only typed write against UserProjectContext. // Callers must supply ExpectedVersion from a prior read; a mismatch fails closed. type ContextUpdate struct { ExpectedVersion int64 BlackboardFill map[string]string BlackboardSet map[string]string SetPendingExpectation bool PendingExpectation *intent.PendingExpectation ExpirePending bool SetInteractiveCard bool InteractiveCard *intent.InteractiveCardPayload TickInteractive bool DismissCardStatus string // cancelled | resolved | expired; then nil SetPersonalizedGuidance bool PersonalizedGuidance string SetRecentDialogue bool RecentDialogue []DialogueTurn ProjectIDIfEmpty string } // ApplyContextUpdate applies one versioned patch. Concurrent writers with the // same ExpectedVersion: exactly one succeeds, the rest get ErrStaleContext. func ApplyContextUpdate(ctx *UserProjectContext, u ContextUpdate) error { if ctx == nil { return ErrNilContext } ctx.mu.Lock() defer ctx.mu.Unlock() if u.ExpectedVersion != ctx.Version { return ErrStaleContext } applied := false if len(u.BlackboardFill) > 0 { if ctx.BlackboardFacts == nil { ctx.BlackboardFacts = make(map[string]string, len(u.BlackboardFill)) } for k, v := range u.BlackboardFill { if _, exists := ctx.BlackboardFacts[k]; !exists { ctx.BlackboardFacts[k] = v } } applied = true } if len(u.BlackboardSet) > 0 { if ctx.BlackboardFacts == nil { ctx.BlackboardFacts = make(map[string]string, len(u.BlackboardSet)) } for k, v := range u.BlackboardSet { ctx.BlackboardFacts[k] = v } applied = true } if u.ExpirePending && ctx.PendingExpectation != nil && !ctx.PendingExpectation.ExpiresAt.IsZero() { // caller decides freshness; apply just clears ctx.PendingExpectation = nil applied = true } if u.SetPendingExpectation { ctx.PendingExpectation = u.PendingExpectation applied = true } if u.TickInteractive && ctx.InteractiveCard != nil { ctx.InteractiveCard.TTLTurns-- if ctx.InteractiveCard.TTLTurns <= 0 || ctx.InteractiveCard.CurrentStatus != "active" { ctx.InteractiveCard.CurrentStatus = "expired" ctx.InteractiveCard = nil } applied = true } if u.DismissCardStatus != "" && ctx.InteractiveCard != nil { ctx.InteractiveCard.CurrentStatus = u.DismissCardStatus ctx.InteractiveCard = nil applied = true } if u.SetInteractiveCard { ctx.InteractiveCard = u.InteractiveCard applied = true } if u.SetPersonalizedGuidance { ctx.PersonalizedGuidance = u.PersonalizedGuidance applied = true } if u.SetRecentDialogue { ctx.RecentDialogue = u.RecentDialogue applied = true } if u.ProjectIDIfEmpty != "" && ctx.Project.ID == "" { ctx.Project.ID = u.ProjectIDIfEmpty applied = true } if !applied { return ErrInvalidUpdate } ctx.Version++ return nil } func (c *UserProjectContext) applyPending(exp *intent.PendingExpectation) { if c == nil { return } _ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, SetPendingExpectation: true, PendingExpectation: exp}) } func (c *UserProjectContext) applyCard(card *intent.InteractiveCardPayload) { if c == nil { return } _ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, SetInteractiveCard: true, InteractiveCard: card}) } func (c *UserProjectContext) dismissCard(status string) { if c == nil { return } _ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, DismissCardStatus: status}) } func (c *UserProjectContext) applyBlackboard(key, value string) { if c == nil { return } _ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, BlackboardSet: map[string]string{key: value}}) }