Skip to content

Commit 4545afb

Browse files
committed
feat(plan): compute deployment plan from desired graph vs state
Implement ComputePlan on Planner comparing normalized JSON per resource (SHA-256 spec_hash) with applied_resources rows. Support create, update with field-level JSON diff, and delete when resources leave the graph. Adds planner.go, diff.go, output.go and unit tests (issue #12). Made-with: Cursor
1 parent df6dc52 commit 4545afb

6 files changed

Lines changed: 592 additions & 0 deletions

File tree

internal/plan/diff.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package plan
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"encoding/json"
7+
"fmt"
8+
"sort"
9+
)
10+
11+
// SpecHashHex is the deployment spec_hash algorithm: SHA-256 over canonical UTF-8 JSON bytes,
12+
// lower-case hex encoding (design doc §14.1 applied_resources.spec_hash).
13+
func SpecHashHex(canonicalJSON []byte) string {
14+
sum := sha256.Sum256(canonicalJSON)
15+
return hex.EncodeToString(sum[:])
16+
}
17+
18+
// canonicalResourceJSON returns compact JSON for a typed resource envelope. Struct field order
19+
// and map key sorting (from encoding/json) keep output stable for the same in-memory value.
20+
func canonicalResourceJSON(v any) ([]byte, error) {
21+
return json.Marshal(v)
22+
}
23+
24+
func jsonDiff(oldJSON, newJSON string) ([]FieldChange, error) {
25+
if oldJSON == newJSON {
26+
return nil, nil
27+
}
28+
var oldV, newV any
29+
if err := json.Unmarshal([]byte(oldJSON), &oldV); err != nil {
30+
return nil, fmt.Errorf("plan: unmarshal old spec json: %w", err)
31+
}
32+
if err := json.Unmarshal([]byte(newJSON), &newV); err != nil {
33+
return nil, fmt.Errorf("plan: unmarshal new spec json: %w", err)
34+
}
35+
return diffAny("", oldV, newV), nil
36+
}
37+
38+
func diffAny(path string, oldV, newV any) []FieldChange {
39+
if jsonEqual(oldV, newV) {
40+
return nil
41+
}
42+
43+
oldMap, okOld := oldV.(map[string]any)
44+
newMap, okNew := newV.(map[string]any)
45+
if okOld && okNew {
46+
return diffObject(path, oldMap, newMap)
47+
}
48+
49+
oldArr, okOldA := oldV.([]any)
50+
newArr, okNewA := newV.([]any)
51+
if okOldA && okNewA {
52+
return diffArray(path, oldArr, newArr)
53+
}
54+
55+
return []FieldChange{{
56+
Path: path,
57+
Old: formatJSONValue(oldV),
58+
New: formatJSONValue(newV),
59+
}}
60+
}
61+
62+
func diffObject(prefix string, oldM, newM map[string]any) []FieldChange {
63+
keys := unionStringKeys(oldM, newM)
64+
sort.Strings(keys)
65+
66+
var out []FieldChange
67+
for _, k := range keys {
68+
p := joinPath(prefix, k)
69+
ov, okO := oldM[k]
70+
nv, okN := newM[k]
71+
switch {
72+
case !okO:
73+
out = append(out, FieldChange{Path: p, Old: "", New: formatJSONValue(nv)})
74+
case !okN:
75+
out = append(out, FieldChange{Path: p, Old: formatJSONValue(ov), New: ""})
76+
default:
77+
out = append(out, diffAny(p, ov, nv)...)
78+
}
79+
}
80+
return out
81+
}
82+
83+
func diffArray(prefix string, oldA, newA []any) []FieldChange {
84+
if jsonEqual(oldA, newA) {
85+
return nil
86+
}
87+
maxLen := len(oldA)
88+
if len(newA) > maxLen {
89+
maxLen = len(newA)
90+
}
91+
var out []FieldChange
92+
for i := 0; i < maxLen; i++ {
93+
p := joinPath(prefix, fmt.Sprintf("%d", i))
94+
var ov, nv any
95+
okO := i < len(oldA)
96+
okN := i < len(newA)
97+
if okO {
98+
ov = oldA[i]
99+
}
100+
if okN {
101+
nv = newA[i]
102+
}
103+
switch {
104+
case !okO:
105+
out = append(out, FieldChange{Path: p, Old: "", New: formatJSONValue(nv)})
106+
case !okN:
107+
out = append(out, FieldChange{Path: p, Old: formatJSONValue(ov), New: ""})
108+
default:
109+
out = append(out, diffAny(p, ov, nv)...)
110+
}
111+
}
112+
return out
113+
}
114+
115+
func joinPath(prefix, key string) string {
116+
if prefix == "" {
117+
return key
118+
}
119+
return prefix + "." + key
120+
}
121+
122+
func unionStringKeys(a, b map[string]any) []string {
123+
seen := map[string]struct{}{}
124+
var out []string
125+
for k := range a {
126+
if _, ok := seen[k]; !ok {
127+
seen[k] = struct{}{}
128+
out = append(out, k)
129+
}
130+
}
131+
for k := range b {
132+
if _, ok := seen[k]; !ok {
133+
seen[k] = struct{}{}
134+
out = append(out, k)
135+
}
136+
}
137+
return out
138+
}
139+
140+
func formatJSONValue(v any) string {
141+
if v == nil {
142+
return "null"
143+
}
144+
b, err := json.Marshal(v)
145+
if err != nil {
146+
return fmt.Sprintf("%v", v)
147+
}
148+
return string(b)
149+
}
150+
151+
// jsonEqual mirrors encoding/json semantic equality for decoded values.
152+
func jsonEqual(a, b any) bool {
153+
if a == nil && b == nil {
154+
return true
155+
}
156+
if a == nil || b == nil {
157+
return false
158+
}
159+
aj, err1 := json.Marshal(a)
160+
bj, err2 := json.Marshal(b)
161+
if err1 != nil || err2 != nil {
162+
return false
163+
}
164+
return string(aj) == string(bj)
165+
}

internal/plan/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
// Package plan computes desired vs current state diffs and risk summaries.
2+
//
3+
// Deployment comparison uses canonical JSON from encoding/json and spec_hash = SHA-256(hex)
4+
// of those bytes (design doc §14.1, issue #12).
25
package plan

internal/plan/output.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package plan
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// FormatPlan renders a short human-readable summary (design doc §10.2).
9+
func FormatPlan(p *Plan) string {
10+
if p == nil {
11+
return ""
12+
}
13+
var nCreate, nUpdate, nDelete int
14+
for _, op := range p.Operations {
15+
switch op.Action {
16+
case ActionCreate:
17+
nCreate++
18+
case ActionUpdate:
19+
nUpdate++
20+
case ActionDelete:
21+
nDelete++
22+
}
23+
}
24+
var b strings.Builder
25+
fmt.Fprintf(&b, "Plan: %d to add, %d to change, %d to delete\n", nCreate, nUpdate, nDelete)
26+
for _, op := range p.Operations {
27+
switch op.Action {
28+
case ActionCreate:
29+
fmt.Fprintf(&b, "+ create %s\n", op.Target.String())
30+
case ActionUpdate:
31+
fmt.Fprintf(&b, "~ update %s\n", op.Target.String())
32+
for _, d := range op.Diff {
33+
fmt.Fprintf(&b, " %s: %s -> %s\n", d.Path, d.Old, d.New)
34+
}
35+
case ActionDelete:
36+
fmt.Fprintf(&b, "- delete %s\n", op.Target.String())
37+
}
38+
}
39+
return strings.TrimSuffix(b.String(), "\n")
40+
}

internal/plan/plan.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,40 @@ import (
44
"context"
55
"errors"
66

7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
78
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
89
)
910

11+
// Action* are [Operation.Action] values (design doc §12.2).
12+
const (
13+
ActionCreate = "create"
14+
ActionUpdate = "update"
15+
ActionDelete = "delete"
16+
)
17+
18+
// Plan is the result of comparing desired project resources to stored deployment rows (§12.2).
19+
type Plan struct {
20+
Operations []Operation
21+
Risk RiskSummary
22+
}
23+
24+
// Operation is one create, update, or delete against a resource identity.
25+
type Operation struct {
26+
Action string
27+
Target spec.ResourceID
28+
Diff []FieldChange
29+
}
30+
31+
// FieldChange is one normalized field-level delta for updates (§10.2 plan output).
32+
type FieldChange struct {
33+
Path string
34+
Old string
35+
New string
36+
}
37+
38+
// RiskSummary is reserved for richer risk deltas (§12.2); MVP planner leaves it empty.
39+
type RiskSummary struct{}
40+
1041
// Planner reads deployment state to compare desired vs applied resources (design doc §5.2).
1142
type Planner struct {
1243
Deploy state.DeploymentStore

0 commit comments

Comments
 (0)