Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions internal/plan/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package plan

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
)

// SpecHashHex is the deployment spec_hash algorithm: SHA-256 over canonical UTF-8 JSON bytes,
// lower-case hex encoding (design doc §14.1 applied_resources.spec_hash).
func SpecHashHex(canonicalJSON []byte) string {
sum := sha256.Sum256(canonicalJSON)
return hex.EncodeToString(sum[:])
}

// canonicalResourceJSON returns compact JSON for a typed resource envelope. Struct field order
// and map key sorting (from encoding/json) keep output stable for the same in-memory value.
func canonicalResourceJSON(v any) ([]byte, error) {
return json.Marshal(v)
}

func jsonDiff(oldJSON, newJSON string) ([]FieldChange, error) {
if oldJSON == newJSON {
return nil, nil
}
var oldV, newV any
if err := json.Unmarshal([]byte(oldJSON), &oldV); err != nil {
return nil, fmt.Errorf("plan: unmarshal old spec json: %w", err)
}
if err := json.Unmarshal([]byte(newJSON), &newV); err != nil {
return nil, fmt.Errorf("plan: unmarshal new spec json: %w", err)
}
return diffAny("", oldV, newV), nil
}

func diffAny(path string, oldV, newV any) []FieldChange {
if jsonEqual(oldV, newV) {
return nil
}

oldMap, okOld := oldV.(map[string]any)
newMap, okNew := newV.(map[string]any)
if okOld && okNew {
return diffObject(path, oldMap, newMap)
}

oldArr, okOldA := oldV.([]any)
newArr, okNewA := newV.([]any)
if okOldA && okNewA {
return diffArray(path, oldArr, newArr)
}

return []FieldChange{{
Path: path,
Old: formatJSONValue(oldV),
New: formatJSONValue(newV),
}}
}

func diffObject(prefix string, oldM, newM map[string]any) []FieldChange {
keys := unionStringKeys(oldM, newM)
sort.Strings(keys)

var out []FieldChange
for _, k := range keys {
p := joinPath(prefix, k)
ov, okO := oldM[k]
nv, okN := newM[k]
switch {
case !okO:
out = append(out, FieldChange{Path: p, Old: "", New: formatJSONValue(nv)})
case !okN:
out = append(out, FieldChange{Path: p, Old: formatJSONValue(ov), New: ""})
default:
out = append(out, diffAny(p, ov, nv)...)
}
}
return out
}

func diffArray(prefix string, oldA, newA []any) []FieldChange {
if jsonEqual(oldA, newA) {
return nil
}
maxLen := len(oldA)
if len(newA) > maxLen {
maxLen = len(newA)
}
var out []FieldChange
for i := 0; i < maxLen; i++ {
p := joinPath(prefix, fmt.Sprintf("%d", i))
var ov, nv any
okO := i < len(oldA)
okN := i < len(newA)
if okO {
ov = oldA[i]
}
if okN {
nv = newA[i]
}
switch {
case !okO:
out = append(out, FieldChange{Path: p, Old: "", New: formatJSONValue(nv)})
case !okN:
out = append(out, FieldChange{Path: p, Old: formatJSONValue(ov), New: ""})
default:
out = append(out, diffAny(p, ov, nv)...)
}
}
return out
}

func joinPath(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}

func unionStringKeys(a, b map[string]any) []string {
seen := map[string]struct{}{}
var out []string
for k := range a {
if _, ok := seen[k]; !ok {
seen[k] = struct{}{}
out = append(out, k)
}
}
for k := range b {
if _, ok := seen[k]; !ok {
seen[k] = struct{}{}
out = append(out, k)
}
}
return out
}

func formatJSONValue(v any) string {
if v == nil {
return "null"
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}

// jsonEqual mirrors encoding/json semantic equality for decoded values.
func jsonEqual(a, b any) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
aj, err1 := json.Marshal(a)
bj, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(aj) == string(bj)
}
3 changes: 3 additions & 0 deletions internal/plan/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
// Package plan computes desired vs current state diffs and risk summaries.
//
// Deployment comparison uses canonical JSON from encoding/json and spec_hash = SHA-256(hex)
// of those bytes (design doc §14.1, issue #12).
package plan
40 changes: 40 additions & 0 deletions internal/plan/output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package plan

import (
"fmt"
"strings"
)

// FormatPlan renders a short human-readable summary (design doc §10.2).
func FormatPlan(p *Plan) string {
if p == nil {
return ""
}
var nCreate, nUpdate, nDelete int
for _, op := range p.Operations {
switch op.Action {
case ActionCreate:
nCreate++
case ActionUpdate:
nUpdate++
case ActionDelete:
nDelete++
}
}
var b strings.Builder
fmt.Fprintf(&b, "Plan: %d to add, %d to change, %d to delete\n", nCreate, nUpdate, nDelete)
for _, op := range p.Operations {
switch op.Action {
case ActionCreate:
fmt.Fprintf(&b, "+ create %s\n", op.Target.String())
case ActionUpdate:
fmt.Fprintf(&b, "~ update %s\n", op.Target.String())
for _, d := range op.Diff {
fmt.Fprintf(&b, " %s: %s -> %s\n", d.Path, d.Old, d.New)
}
case ActionDelete:
fmt.Fprintf(&b, "- delete %s\n", op.Target.String())
}
}
return strings.TrimSuffix(b.String(), "\n")
}
31 changes: 31 additions & 0 deletions internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,40 @@ import (
"context"
"errors"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
)

// Action* are [Operation.Action] values (design doc §12.2).
const (
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
)

// Plan is the result of comparing desired project resources to stored deployment rows (§12.2).
type Plan struct {
Operations []Operation
Risk RiskSummary
}

// Operation is one create, update, or delete against a resource identity.
type Operation struct {
Action string
Target spec.ResourceID
Diff []FieldChange
}

// FieldChange is one normalized field-level delta for updates (§10.2 plan output).
type FieldChange struct {
Path string
Old string
New string
}

// RiskSummary is reserved for richer risk deltas (§12.2); MVP planner leaves it empty.
type RiskSummary struct{}

// Planner reads deployment state to compare desired vs applied resources (design doc §5.2).
type Planner struct {
Deploy state.DeploymentStore
Expand Down
Loading
Loading