package roadmapsync import "strings" // Step is the provider-neutral creation-cycle step the retry decision resumes // from. The values mirror the persisted step ledger so a caller can read the // ledger and pass the completed set in without re-mapping strings. type Step string const ( StepDevelopMatched Step = "develop_matched" StepOriginalCommentPreserved Step = "original_comment_preserved" StepPlaneBodyUpdated Step = "plane_body_updated" StepPlaneTodoMoved Step = "plane_todo_moved" ) // OrderedSteps is the creation-cycle order. A reconcile resumes at the first // step not present in the completed set. var OrderedSteps = []Step{ StepDevelopMatched, StepOriginalCommentPreserved, StepPlaneBodyUpdated, StepPlaneTodoMoved, } // Action is the next thing a creation cycle should do after reconciling the // persisted identity and step ledger against the current trigger. type Action string const ( // ActionResume runs the remaining steps starting at NextStep. ActionResume Action = "resume" // ActionComplete means every step is already recorded; nothing to do. ActionComplete Action = "complete" // ActionConflict means the trigger disagrees with the persisted identity // (revision mismatch) and a human/upstream decision is required instead of a // silent overwrite. ActionConflict Action = "conflict" // ActionSkipSelfMutation means the trigger was NomadCode's own mutation, so // it must not be treated as a new authoring trigger. ActionSkipSelfMutation Action = "skip_self_mutation" ) // ExistingIdentity is the provider-neutral view of a persisted identity row the // reconcile needs: the stored identity plus the revisions it was last written // with. Found is false when no identity is persisted yet. type ExistingIdentity struct { Found bool Identity Identity ProviderRevision string RoadmapRevision string } // ReconcileInput carries the current trigger and the persisted state to // reconcile it against. Existing and CompletedSteps come from the identity map // and step ledger; an empty CompletedSteps means a fresh cycle. type ReconcileInput struct { // Identity is the trigger's provider-neutral identity (the Plane ticket or // Milestone path being re-processed). Identity Identity // ProviderRevision / RoadmapRevision are the revisions the current trigger // observed. A mismatch against the persisted revisions is a conflict. ProviderRevision string RoadmapRevision string // Actor is who caused this trigger. When it equals SelfActor the trigger is // NomadCode's own mutation and is not a new authoring trigger. Actor string SelfActor string Existing ExistingIdentity CompletedSteps map[Step]bool } // ReconcileResult is the typed decision: what to do next, the resume step when // ActionResume, whether an existing identity was reused, and a reason that is // always populated so a not-resume decision is never silent. type ReconcileResult struct { Action Action NextStep Step IdentityKept bool Reason string } // Reasons for a reconcile decision. Stable strings so a caller can branch on // the cause. const ( reasonInvalidReconcileIdentity = "trigger identity is invalid or incomplete" reasonIdentityKeyMismatch = "persisted identity key does not match trigger (provider/tenant/project/work item/path)" reasonSelfMutation = "trigger actor is NomadCode self mutation, not a new authoring trigger" reasonProviderRevMismatch = "trigger provider revision does not match persisted identity" reasonRoadmapRevMismatch = "trigger roadmap revision does not match persisted identity" reasonAllStepsComplete = "all creation cycle steps already completed" reasonResumeRemaining = "resuming creation cycle at first incomplete step" ) // ReconcileCreationCycle decides the next action for a (possibly re-processed) // creation cycle. It is the idempotency join described by plan 05: it reuses an // existing identity when the same Plane ticket or Milestone path is // re-processed, refuses to overwrite on a revision mismatch, skips NomadCode's // own mutations, and otherwise resumes at the first step not yet in the ledger. // // The function reads no DB itself; callers supply the persisted identity and // completed-step set so the decision stays provider-neutral and unit-testable. func ReconcileCreationCycle(in ReconcileInput) ReconcileResult { identity, err := in.Identity.Normalize() if err != nil { return ReconcileResult{Action: ActionConflict, Reason: reasonInvalidReconcileIdentity} } // Actor guard: a mutation NomadCode itself made (e.g. the body/title rewrite // it just projected) must not be re-interpreted as a fresh user trigger. self := strings.TrimSpace(in.SelfActor) if self != "" && strings.TrimSpace(in.Actor) == self { return ReconcileResult{Action: ActionSkipSelfMutation, Reason: reasonSelfMutation} } // Match: when a persisted identity exists it must agree on the minimal key // (provider and canonical Milestone id and work item id). A found-but-not-matching // row means the trigger disagrees with what is stored (e.g. different provider or // different work item id), which is a conflict rather than a silent fresh resume. // Changes to path, tenant, project are allowed safe attribute updates. if in.Existing.Found && !in.Existing.Identity.Matches(identity) { return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonIdentityKeyMismatch} } identityKept := in.Existing.Found // Revision guard: when an identity already exists for this milestone id, // the trigger must observe the same revisions, otherwise resuming would // silently overwrite work projected from a different develop state. if identityKept { if mismatch(in.Existing.ProviderRevision, in.ProviderRevision) { return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonProviderRevMismatch} } if mismatch(in.Existing.RoadmapRevision, in.RoadmapRevision) { return ReconcileResult{Action: ActionConflict, IdentityKept: true, Reason: reasonRoadmapRevMismatch} } } next, ok := firstIncompleteStep(in.CompletedSteps) if !ok { return ReconcileResult{Action: ActionComplete, IdentityKept: identityKept, Reason: reasonAllStepsComplete} } return ReconcileResult{ Action: ActionResume, NextStep: next, IdentityKept: identityKept, Reason: reasonResumeRemaining, } } // firstIncompleteStep returns the earliest ordered step not present in the // completed set. ok is false when every step is complete. func firstIncompleteStep(completed map[Step]bool) (Step, bool) { for _, step := range OrderedSteps { if !completed[step] { return step, true } } return "", false } // mismatch reports whether a persisted revision and a trigger revision disagree. // Empty values are treated as "unknown" and never conflict, so a first cycle // that has not recorded a revision is not blocked. func mismatch(persisted, trigger string) bool { p := strings.TrimSpace(persisted) t := strings.TrimSpace(trigger) if p == "" || t == "" { return false } return p != t }