feat(events): apply version-committed projections only forward
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,6 +62,7 @@ imports:
|
||||
- app/docadvance
|
||||
- app/docflow
|
||||
- app/docnotify
|
||||
- app/docproject
|
||||
- app/stageflow
|
||||
- app/uow
|
||||
- artifactvault
|
||||
@@ -96,6 +97,9 @@ imports:
|
||||
- notification
|
||||
- projectinstance
|
||||
- projectworkflow
|
||||
app/docproject:
|
||||
- docgov
|
||||
- events
|
||||
app/docflow:
|
||||
- app/uow
|
||||
- docgov
|
||||
@@ -255,6 +259,8 @@ table_owners:
|
||||
- events
|
||||
event_reconciliations:
|
||||
- events
|
||||
event_projection_outcomes:
|
||||
- app/docproject
|
||||
notifications:
|
||||
- notification
|
||||
stage_advance_requests:
|
||||
@@ -308,6 +314,7 @@ event_producers:
|
||||
- docrevision
|
||||
- app/docflow
|
||||
- events
|
||||
- app/docproject
|
||||
document.downstream_impact_detected:
|
||||
- docrevision
|
||||
- app/docflow
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"chat-agent-system/internal/app/docadvance"
|
||||
"chat-agent-system/internal/app/docnotify"
|
||||
"chat-agent-system/internal/app/docproject"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
@@ -77,6 +78,9 @@ func main() {
|
||||
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
|
||||
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
|
||||
}).HandleLease)
|
||||
_ = disp.Register(events.ConsumerProjection, (&docproject.EventConsumer{
|
||||
Docs: gov, Env: cfg.Env,
|
||||
}).HandleLease)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "env": cfg.Env, "owner": owner})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package docproject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/events"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// EventConsumer applies document.agent_version_committed onto ledger head pointers.
|
||||
// Closures/producer already project in the approve/commit UoW; this path is idempotent no-replace.
|
||||
type EventConsumer struct {
|
||||
Docs DocumentLookup
|
||||
Env string
|
||||
}
|
||||
|
||||
type DocumentLookup interface {
|
||||
GetDocument(ctx context.Context, docID string) (*docgov.DocumentRecord, error)
|
||||
}
|
||||
|
||||
func (c *EventConsumer) HandleLease(ctx context.Context, lease events.Lease, tx pgx.Tx) error {
|
||||
if c == nil {
|
||||
return events.ErrUnknownConsumer
|
||||
}
|
||||
principal := events.ServicePrincipal{
|
||||
ID: "projection-" + c.Env, Environment: c.Env, Consumer: events.ConsumerProjection,
|
||||
Actions: []string{"projection.apply"},
|
||||
}
|
||||
if err := events.AuthorizePrincipal(principal, lease.Environment, lease.ProjectID, "projection.apply"); err != nil {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
if lease.EventName != events.EventVersionCommitted {
|
||||
return nil
|
||||
}
|
||||
docID := lease.Envelope.AggregateID
|
||||
versionNo := int(lease.Envelope.AggregateVersion)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(lease.Envelope.Payload, &payload)
|
||||
if payload != nil {
|
||||
if id, ok := payload["doc_id"].(string); ok && id != "" {
|
||||
docID = id
|
||||
}
|
||||
if v, ok := payload["version_no"].(float64); ok {
|
||||
versionNo = int(v)
|
||||
}
|
||||
}
|
||||
if c.Docs == nil || docID == "" {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
doc, err := c.Docs.GetDocument(ctx, docID)
|
||||
if err != nil || doc == nil {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
if doc.ProjectID != lease.ProjectID {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
var versionID, snapshotID int64
|
||||
var sha string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT v.id, COALESCE(v.head_snapshot_id,0), COALESCE(s.content_sha256,'')
|
||||
FROM document_versions v
|
||||
LEFT JOIN document_snapshots s ON s.id = v.head_snapshot_id
|
||||
WHERE v.doc_id=$1 AND v.version_no=$2
|
||||
`, docID, versionNo).Scan(&versionID, &snapshotID, &sha)
|
||||
if err != nil {
|
||||
return events.ErrQuarantine
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO event_projection_outcomes (source_event_id, doc_id, version_no, snapshot_id, applied)
|
||||
VALUES ($1,$2,$3,$4,false)
|
||||
ON CONFLICT (source_event_id) DO NOTHING
|
||||
`, lease.EventID, docID, versionNo, snapshotID)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil
|
||||
}
|
||||
applied, err := (docgov.Repository{}).ProjectHeadNoReplace(ctx, tx, docID, versionID, snapshotID, versionNo, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE event_projection_outcomes SET applied=$2, snapshot_id=$3 WHERE source_event_id=$1
|
||||
`, lease.EventID, applied, snapshotID)
|
||||
return err
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Repository is the document_records writer bound to a UnitOfWork scope.
|
||||
@@ -106,3 +108,23 @@ func (Repository) ProjectHead(ctx context.Context, scope uow.TxScope, docID stri
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectHeadNoReplace moves ledger pointers only forward. Equal or newer heads stay.
|
||||
func (Repository) ProjectHeadNoReplace(ctx context.Context, tx pgx.Tx, docID string, versionID, snapshotID int64, revisionNo int, sha string) (bool, error) {
|
||||
if tx == nil || docID == "" {
|
||||
return false, fmt.Errorf("docgov: project head requires tx and doc_id")
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE document_records
|
||||
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
|
||||
row_revision=COALESCE(row_revision,0)+1,
|
||||
content_sha256=COALESCE(NULLIF($5,''), content_sha256),
|
||||
updated_at=now()
|
||||
WHERE doc_id=$1
|
||||
AND COALESCE(head_revision_no, 0) < $4
|
||||
`, docID, versionID, snapshotID, revisionNo, sha)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("docgov: project head no-replace: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
+14
-14
@@ -12,24 +12,24 @@ const (
|
||||
ConsumerWorkflowAdvance = "workflow_advance"
|
||||
ConsumerFAV = "fav"
|
||||
|
||||
EventCandidateReady = "document.agent_candidate_ready"
|
||||
EventVersionCommitted = "document.agent_version_committed"
|
||||
EventDownstreamImpact = "document.downstream_impact_detected"
|
||||
EventCandidateReady = "document.agent_candidate_ready"
|
||||
EventVersionCommitted = "document.agent_version_committed"
|
||||
EventDownstreamImpact = "document.downstream_impact_detected"
|
||||
EventChangeReportDegraded = "document.change_report_degraded"
|
||||
EventApproved = "document.approved"
|
||||
EventTaskRunCompleted = "taskrun.completed"
|
||||
EventTaskRunCancelled = "taskrun.cancelled"
|
||||
EventFAVArtifactStaged = "fav.artifact_staged"
|
||||
EventApproved = "document.approved"
|
||||
EventTaskRunCompleted = "taskrun.completed"
|
||||
EventTaskRunCancelled = "taskrun.cancelled"
|
||||
EventFAVArtifactStaged = "fav.artifact_staged"
|
||||
)
|
||||
|
||||
// Contract is the static allowlist row for one event name.
|
||||
type Contract struct {
|
||||
EventName string
|
||||
SchemaVersion int
|
||||
Enabled bool
|
||||
Producers []string
|
||||
Consumers []string
|
||||
PayloadFields []string
|
||||
EventName string
|
||||
SchemaVersion int
|
||||
Enabled bool
|
||||
Producers []string
|
||||
Consumers []string
|
||||
PayloadFields []string
|
||||
}
|
||||
|
||||
// StaticContracts is the runtime copy of module-boundaries event sections.
|
||||
@@ -37,7 +37,7 @@ type Contract struct {
|
||||
func StaticContracts() []Contract {
|
||||
return []Contract{
|
||||
{EventName: EventCandidateReady, SchemaVersion: 1, Enabled: true, Producers: []string{"docrevision", "app/docflow", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "candidate_id", "risk"}},
|
||||
{EventName: EventVersionCommitted, SchemaVersion: 1, Enabled: true, Producers: []string{"docrevision", "app/docflow", "events"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"doc_id", "version_no"}},
|
||||
{EventName: EventVersionCommitted, SchemaVersion: 1, Enabled: true, Producers: []string{"docrevision", "app/docflow", "events", "app/docproject"}, Consumers: []string{ConsumerProjection, ConsumerNotify}, PayloadFields: []string{"doc_id", "version_no"}},
|
||||
{EventName: EventDownstreamImpact, SchemaVersion: 1, Enabled: true, Producers: []string{"docrevision", "app/docflow", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "impacted_doc_id", "version_no"}},
|
||||
{EventName: EventChangeReportDegraded, SchemaVersion: 1, Enabled: true, Producers: []string{"docrevision", "app/docflow", "events"}, Consumers: []string{ConsumerNotify}, PayloadFields: []string{"doc_id", "report_id"}},
|
||||
{EventName: EventApproved, SchemaVersion: 1, Enabled: true, Producers: []string{"app/docflow", "events", "app/docadvance"}, Consumers: []string{ConsumerWorkflowAdvance, ConsumerNotify}, PayloadFields: []string{"doc_id", "project_id", "task_key"}},
|
||||
|
||||
Reference in New Issue
Block a user