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
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
.PHONY: test build

fmt:
go fmt ./...

test:
go test ./... -race

test-coverage:
go test ./... -race -coverprofile=coverage.out

vet:
go vet ./...

build:
mkdir -p bin
go build -o bin/agentctl ./cmd/agentctl
59 changes: 59 additions & 0 deletions internal/apply/applier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package apply

import (
"context"
"errors"
"time"

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

// Applier mutates deployment state from an apply operation (design doc §5.2, §12.2 D).
type Applier struct {
Deploy state.DeploymentStore
}

// NewApplier returns an applier backed by dep.
func NewApplier(dep state.DeploymentStore) *Applier {
return &Applier{Deploy: dep}
}

// RecordAppliedResource upserts one applied resource row.
func (a *Applier) RecordAppliedResource(ctx context.Context, r state.AppliedResource) error {
if a == nil || a.Deploy == nil {
return errors.New("apply: nil applier or deployment store")
}
return a.Deploy.UpsertAppliedResource(ctx, r)
}

// ApplyPlan persists all plan operations and updates applied_projects for env (issue #15).
// When dep implements [state.TransactionalDeployment] (e.g. SQLite), the whole apply runs in one transaction.
func (a *Applier) ApplyPlan(ctx context.Context, env string, g *spec.ProjectGraph, p *plan.Plan, at time.Time) error {
if a == nil || a.Deploy == nil {
return errors.New("apply: nil applier or deployment store")
}
if g == nil {
return errors.New("apply: nil project graph")
}
if p == nil {
return errors.New("apply: nil plan")
}
if env == "" {
return errors.New("apply: empty env")
}
projectName, projectVersion, err := plan.ProjectDeploymentMeta(g)
if err != nil {
return err
}
at = at.UTC()

run := func(ctx context.Context, dep state.DeploymentStore) error {
return executePlan(ctx, dep, env, p, at, projectName, projectVersion)
}
if tx, ok := a.Deploy.(state.TransactionalDeployment); ok {
return tx.RunDeploymentTx(ctx, run)
}
return run(ctx, a.Deploy)
}
129 changes: 129 additions & 0 deletions internal/apply/applier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package apply

import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"

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

func minimalGraph() *spec.ProjectGraph {
return &spec.ProjectGraph{
Meta: spec.Metadata{Name: "acme"},
Spec: spec.ProjectSpec{},
Agents: map[string]*spec.AgentResource{},
Tools: map[string]*spec.ToolResource{},
Workflows: map[string]*spec.WorkflowResource{},
Policies: map[string]*spec.PolicyResource{},
Environments: map[string]*spec.EnvironmentResource{},
}
}

func graphWithAgent() *spec.ProjectGraph {
g := minimalGraph()
g.Agents["rev"] = &spec.AgentResource{
APIVersion: spec.APIVersionV0,
Kind: spec.KindAgent,
Metadata: spec.Metadata{Name: "rev"},
Spec: spec.AgentSpec{Model: "m", Policy: "default"},
}
return g
}

func TestApplyPlan_thenListShowsResources(t *testing.T) {
ctx := context.Background()
st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "apply.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })

g := minimalGraph()
pl := plan.NewPlanner(st)
p, err := pl.ComputePlan(ctx, "dev", g)
if err != nil {
t.Fatal(err)
}
at := time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC)
ap := NewApplier(st)
if err := ap.ApplyPlan(ctx, "dev", g, p, at); err != nil {
t.Fatal(err)
}

list, err := st.ListAppliedResourcesByEnv(ctx, "dev")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("resources: %+v", list)
}
if list[0].Kind != spec.KindProject || list[0].Name != "acme" {
t.Fatalf("got %+v", list[0])
}
got, err := st.GetAppliedResource(ctx, "dev", spec.ResourceID{Kind: spec.KindProject, Name: "acme"})
if err != nil {
t.Fatal(err)
}
if got.SpecHash == "" || got.NormalizedSpecJSON == "" {
t.Fatalf("missing spec material: %+v", got)
}

proj, err := st.GetAppliedProject(ctx, "dev", "acme")
if err != nil {
t.Fatal(err)
}
if proj.Version == "" {
t.Fatalf("applied_projects.version empty: %+v", proj)
}
}

func TestApplyPlan_deleteRemovesRow(t *testing.T) {
ctx := context.Background()
st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "apply-del.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })

pl := plan.NewPlanner(st)
ap := NewApplier(st)
t0 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Hour)

gFull := graphWithAgent()
p1, err := pl.ComputePlan(ctx, "dev", gFull)
if err != nil {
t.Fatal(err)
}
if err := ap.ApplyPlan(ctx, "dev", gFull, p1, t0); err != nil {
t.Fatal(err)
}

gOnly := minimalGraph()
p2, err := pl.ComputePlan(ctx, "dev", gOnly)
if err != nil {
t.Fatal(err)
}
if err := ap.ApplyPlan(ctx, "dev", gOnly, p2, t1); err != nil {
t.Fatal(err)
}

_, err = st.GetAppliedResource(ctx, "dev", spec.ResourceID{Kind: spec.KindAgent, Name: "rev"})
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("agent row should be gone: %v", err)
}

list, err := st.ListAppliedResourcesByEnv(ctx, "dev")
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Kind != spec.KindProject {
t.Fatalf("want project only, got %+v", list)
}
}
26 changes: 0 additions & 26 deletions internal/apply/apply.go

This file was deleted.

3 changes: 3 additions & 0 deletions internal/apply/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
// Package apply applies plans to control-plane and runtime state.
//
// [Applier.ApplyPlan] writes applied_resources and applied_projects using [plan.Plan] operations.
// SQLite uses a single transaction via [state.TransactionalDeployment] (issue #15).
package apply
51 changes: 51 additions & 0 deletions internal/apply/executor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package apply

import (
"context"
"fmt"
"strings"
"time"

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

func executePlan(
ctx context.Context,
dep state.DeploymentStore,
env string,
p *plan.Plan,
at time.Time,
projectName, projectVersion string,
) error {
for _, op := range p.Operations {
switch op.Action {
case plan.ActionCreate, plan.ActionUpdate:
if strings.TrimSpace(op.SpecHash) == "" || strings.TrimSpace(op.NormalizedSpecJSON) == "" {
return fmt.Errorf("apply: %s operation for %s missing spec hash or normalized JSON", op.Action, op.Target.String())
}
if err := dep.UpsertAppliedResource(ctx, state.AppliedResource{
Kind: op.Target.Kind,
Name: op.Target.Name,
Env: env,
SpecHash: op.SpecHash,
NormalizedSpecJSON: op.NormalizedSpecJSON,
AppliedAt: at,
}); err != nil {
return err
}
case plan.ActionDelete:
if err := dep.DeleteAppliedResource(ctx, env, op.Target); err != nil {
return err
}
default:
return fmt.Errorf("apply: unknown operation action %q", op.Action)
}
}
return dep.UpsertAppliedProject(ctx, state.AppliedProject{
ProjectName: projectName,
Env: env,
Version: projectVersion,
AppliedAt: at,
})
}
9 changes: 6 additions & 3 deletions internal/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ type Plan struct {
}

// Operation is one create, update, or delete against a resource identity.
// SpecHash and NormalizedSpecJSON are set for create and update (apply material); they are empty for delete.
type Operation struct {
Action string
Target spec.ResourceID
Diff []FieldChange
Action string
Target spec.ResourceID
Diff []FieldChange
SpecHash string
NormalizedSpecJSON string
}

// FieldChange is one normalized field-level delta for updates (§10.2 plan output).
Expand Down
4 changes: 4 additions & 0 deletions internal/plan/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func (s *stubDeploy) GetAppliedProject(context.Context, string, string) (*state.
return nil, errors.New("stub")
}

func (s *stubDeploy) DeleteAppliedResource(context.Context, string, spec.ResourceID) error {
return errors.New("stub")
}

func TestPlanner_listAppliedResources_usesDeploymentStoreOnly(t *testing.T) {
st := &stubDeploy{list: []state.AppliedResource{{Name: "agent-a", Env: "dev", Kind: "Agent"}}}
p := plan.NewPlanner(st)
Expand Down
16 changes: 14 additions & 2 deletions internal/plan/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr
key := resourceMapKey(d.id.Kind, d.id.Name)
prev, ok := appliedByID[key]
if !ok {
ops = append(ops, Operation{Action: ActionCreate, Target: d.id, Diff: nil})
ops = append(ops, Operation{
Action: ActionCreate,
Target: d.id,
Diff: nil,
SpecHash: d.hash,
NormalizedSpecJSON: d.json,
})
continue
}
if prev.SpecHash == d.hash {
Expand All @@ -68,7 +74,13 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr
if err != nil {
return nil, err
}
ops = append(ops, Operation{Action: ActionUpdate, Target: d.id, Diff: diff})
ops = append(ops, Operation{
Action: ActionUpdate,
Target: d.id,
Diff: diff,
SpecHash: d.hash,
NormalizedSpecJSON: d.json,
})
}

for _, r := range applied {
Expand Down
4 changes: 4 additions & 0 deletions internal/plan/planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func (f *fakeDeploy) GetAppliedProject(context.Context, string, string) (*state.
return nil, nil
}

func (f *fakeDeploy) DeleteAppliedResource(context.Context, string, spec.ResourceID) error {
return nil
}

func minimalGraph() *spec.ProjectGraph {
return &spec.ProjectGraph{
Meta: spec.Metadata{Name: "acme"},
Expand Down
25 changes: 25 additions & 0 deletions internal/plan/project_meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package plan

import (
"fmt"

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

// ProjectDeploymentMeta returns the Project resource name and spec_hash for applied_projects.version
// after the same normalization as [Planner.ComputePlan] (issue #15).
func ProjectDeploymentMeta(g *spec.ProjectGraph) (projectName string, projectSpecHash string, err error) {
if g == nil {
return "", "", fmt.Errorf("plan: nil project graph")
}
rows, err := desiredRows(g)
if err != nil {
return "", "", err
}
for _, r := range rows {
if r.id.Kind == spec.KindProject {
return r.id.Name, r.hash, nil
}
}
return "", "", fmt.Errorf("plan: graph has no Project resource")
}
3 changes: 2 additions & 1 deletion internal/state/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package state stores deployment state and runtime metadata (design doc §5.2, §14).
//
// [DeploymentStore] and [RuntimeStore] are the boundaries plan, apply, and runtime code should use
// [DeploymentStore], [TransactionalDeployment], and [RuntimeStore] are the boundaries plan, apply,
// and runtime code should use
// so callers do not depend on a specific SQL backend. MVP implements them in internal/state/sqlite.
//
// Thread-safety: interfaces assume a single-process CLI unless a concrete backend documents
Expand Down
Loading
Loading