Skip to content

Commit b9e27aa

Browse files
authored
Add state machine to apps-mcp (#4160)
## Changes Add state machine to apps-mcp to enforce workflow transitions: scaffolded → validated → deployed. ## Why Prevents deploying code that hasn't been validated, or deploying stale code that changed since validation. ## Tests Manual testing
1 parent c6fe5e8 commit b9e27aa

File tree

7 files changed

+392
-1
lines changed

7 files changed

+392
-1
lines changed

experimental/apps-mcp/cmd/deploy.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/databricks/cli/bundle/run"
1111
"github.com/databricks/cli/cmd/bundle/utils"
1212
"github.com/databricks/cli/cmd/root"
13+
"github.com/databricks/cli/experimental/apps-mcp/lib/state"
1314
"github.com/databricks/cli/experimental/apps-mcp/lib/validation"
1415
"github.com/databricks/cli/libs/cmdio"
1516
"github.com/databricks/cli/libs/log"
@@ -40,10 +41,37 @@ The command will stop immediately if any step fails.`,
4041

4142
func deployRun(cmd *cobra.Command, args []string) error {
4243
ctx := cmd.Context()
44+
workDir := "."
45+
46+
// Load and verify state
47+
currentState, err := state.LoadState(workDir)
48+
if err != nil {
49+
return fmt.Errorf("failed to load state: %w", err)
50+
}
51+
if currentState == nil {
52+
return errors.New("cannot deploy: project not validated (run validate first)")
53+
}
54+
55+
// Verify checksum before deploy
56+
if currentState.GetChecksum() != "" {
57+
match, err := state.VerifyChecksum(workDir, currentState.GetChecksum())
58+
if err != nil {
59+
return fmt.Errorf("failed to verify checksum: %w", err)
60+
}
61+
if !match {
62+
return errors.New("cannot deploy: code changed since validation (run validate again)")
63+
}
64+
}
65+
66+
// Check state transition is valid
67+
newState, err := currentState.Deploy()
68+
if err != nil {
69+
return err
70+
}
4371

4472
log.Infof(ctx, "Running Node.js validation...")
4573
validator := &validation.ValidationNodeJs{}
46-
result, err := validator.Validate(ctx, ".")
74+
result, err := validator.Validate(ctx, workDir)
4775
if err != nil {
4876
return fmt.Errorf("validation error: %w", err)
4977
}
@@ -78,6 +106,11 @@ func deployRun(cmd *cobra.Command, args []string) error {
78106
return fmt.Errorf("failed to run app: %w", err)
79107
}
80108

109+
// Save deployed state
110+
if err := state.SaveState(workDir, newState); err != nil {
111+
return fmt.Errorf("failed to save state: %w", err)
112+
}
113+
81114
return nil
82115
}
83116

experimental/apps-mcp/cmd/init_template.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/databricks/cli/cmd/root"
1414
"github.com/databricks/cli/experimental/apps-mcp/lib/common"
1515
"github.com/databricks/cli/experimental/apps-mcp/lib/prompts"
16+
"github.com/databricks/cli/experimental/apps-mcp/lib/state"
1617
"github.com/databricks/cli/libs/cmdio"
1718
"github.com/databricks/cli/libs/template"
1819
"github.com/spf13/cobra"
@@ -343,6 +344,11 @@ After initialization:
343344
// Inject L3 (template-specific guidance from CLAUDE.md)
344345
readClaudeMd(ctx, configFile)
345346

347+
// Save initial scaffolded state
348+
if err := state.SaveState(absOutputDir, state.NewScaffolded()); err != nil {
349+
return fmt.Errorf("failed to save project state: %w", err)
350+
}
351+
346352
return nil
347353
}
348354
return cmd

experimental/apps-mcp/cmd/validate.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88

9+
"github.com/databricks/cli/experimental/apps-mcp/lib/state"
910
"github.com/databricks/cli/experimental/apps-mcp/lib/validation"
1011
"github.com/databricks/cli/libs/cmdio"
1112
"github.com/spf13/cobra"
@@ -79,6 +80,28 @@ Exit codes:
7980
if !result.Success {
8081
return errors.New("validation failed")
8182
}
83+
84+
// Compute checksum and transition to validated state
85+
checksum, err := state.ComputeChecksum(absPath)
86+
if err != nil {
87+
return fmt.Errorf("failed to compute checksum: %w", err)
88+
}
89+
90+
// Load current state or create new scaffolded state
91+
currentState, err := state.LoadState(absPath)
92+
if err != nil {
93+
return fmt.Errorf("failed to load state: %w", err)
94+
}
95+
if currentState == nil {
96+
currentState = state.NewScaffolded()
97+
}
98+
99+
// Transition to validated
100+
newState := currentState.Validate(checksum)
101+
if err := state.SaveState(absPath, newState); err != nil {
102+
return fmt.Errorf("failed to save state: %w", err)
103+
}
104+
82105
return nil
83106
},
84107
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package state
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"slices"
12+
"strings"
13+
)
14+
15+
var (
16+
// directories to scan for source files
17+
sourceDirs = []string{"client", "server"}
18+
19+
// directories to exclude
20+
excludeDirs = map[string]bool{
21+
"node_modules": true,
22+
"dist": true,
23+
".git": true,
24+
"build": true,
25+
"coverage": true,
26+
}
27+
28+
// file extensions to include
29+
sourceExtensions = map[string]bool{
30+
".ts": true,
31+
".tsx": true,
32+
".js": true,
33+
".jsx": true,
34+
".json": true,
35+
".css": true,
36+
".html": true,
37+
".yaml": true,
38+
".yml": true,
39+
}
40+
)
41+
42+
// ComputeChecksum computes SHA256 checksum of all source files
43+
func ComputeChecksum(workDir string) (string, error) {
44+
var files []string
45+
46+
// collect files from source directories
47+
for _, dir := range sourceDirs {
48+
dirPath := filepath.Join(workDir, dir)
49+
if _, err := os.Stat(dirPath); err == nil {
50+
if err := collectSourceFiles(dirPath, &files); err != nil {
51+
return "", err
52+
}
53+
}
54+
}
55+
56+
// include root package.json
57+
packageJSON := filepath.Join(workDir, "package.json")
58+
if _, err := os.Stat(packageJSON); err == nil {
59+
files = append(files, packageJSON)
60+
}
61+
62+
// sort for deterministic order
63+
slices.Sort(files)
64+
65+
if len(files) == 0 {
66+
return "", errors.New("no source files found - project structure appears invalid")
67+
}
68+
69+
// compute combined hash
70+
hasher := sha256.New()
71+
for _, file := range files {
72+
if err := hashFile(hasher, file); err != nil {
73+
return "", fmt.Errorf("failed to hash %s: %w", file, err)
74+
}
75+
}
76+
77+
return hex.EncodeToString(hasher.Sum(nil)), nil
78+
}
79+
80+
// VerifyChecksum verifies that current checksum matches expected
81+
func VerifyChecksum(workDir, expected string) (bool, error) {
82+
current, err := ComputeChecksum(workDir)
83+
if err != nil {
84+
return false, err
85+
}
86+
return current == expected, nil
87+
}
88+
89+
func collectSourceFiles(dir string, files *[]string) error {
90+
entries, err := os.ReadDir(dir)
91+
if err != nil {
92+
return fmt.Errorf("failed to read directory %s: %w", dir, err)
93+
}
94+
95+
for _, entry := range entries {
96+
path := filepath.Join(dir, entry.Name())
97+
98+
if entry.IsDir() {
99+
if excludeDirs[entry.Name()] {
100+
continue
101+
}
102+
if err := collectSourceFiles(path, files); err != nil {
103+
return err
104+
}
105+
} else {
106+
ext := strings.ToLower(filepath.Ext(entry.Name()))
107+
if sourceExtensions[ext] {
108+
*files = append(*files, path)
109+
}
110+
}
111+
}
112+
113+
return nil
114+
}
115+
116+
func hashFile(hasher io.Writer, path string) error {
117+
f, err := os.Open(path)
118+
if err != nil {
119+
return err
120+
}
121+
defer f.Close()
122+
123+
_, err = io.Copy(hasher, f)
124+
return err
125+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package state
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestComputeChecksum(t *testing.T) {
13+
dir := t.TempDir()
14+
15+
// create client/ and server/ dirs
16+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "client"), 0o755))
17+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "server"), 0o755))
18+
19+
// create source files
20+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "app.ts"), []byte("console.log('hello')"), 0o644))
21+
require.NoError(t, os.WriteFile(filepath.Join(dir, "server", "main.ts"), []byte("export default {}"), 0o644))
22+
require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name": "test"}`), 0o644))
23+
24+
// compute checksum
25+
checksum1, err := ComputeChecksum(dir)
26+
require.NoError(t, err)
27+
assert.Len(t, checksum1, 64) // sha256 hex
28+
29+
// same content = same checksum
30+
checksum2, err := ComputeChecksum(dir)
31+
require.NoError(t, err)
32+
assert.Equal(t, checksum1, checksum2)
33+
34+
// modify file = different checksum
35+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "app.ts"), []byte("console.log('changed')"), 0o644))
36+
checksum3, err := ComputeChecksum(dir)
37+
require.NoError(t, err)
38+
assert.NotEqual(t, checksum1, checksum3)
39+
}
40+
41+
func TestComputeChecksumExcludesNodeModules(t *testing.T) {
42+
dir := t.TempDir()
43+
44+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "client"), 0o755))
45+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "client", "node_modules"), 0o755))
46+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "app.ts"), []byte("code"), 0o644))
47+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "node_modules", "dep.js"), []byte("dependency"), 0o644))
48+
49+
checksum1, err := ComputeChecksum(dir)
50+
require.NoError(t, err)
51+
52+
// changing node_modules should not affect checksum
53+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "node_modules", "dep.js"), []byte("changed"), 0o644))
54+
checksum2, err := ComputeChecksum(dir)
55+
require.NoError(t, err)
56+
57+
assert.Equal(t, checksum1, checksum2)
58+
}
59+
60+
func TestComputeChecksumEmptyProject(t *testing.T) {
61+
dir := t.TempDir()
62+
63+
_, err := ComputeChecksum(dir)
64+
assert.ErrorContains(t, err, "no source files found")
65+
}
66+
67+
func TestVerifyChecksum(t *testing.T) {
68+
dir := t.TempDir()
69+
70+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "client"), 0o755))
71+
require.NoError(t, os.WriteFile(filepath.Join(dir, "client", "app.ts"), []byte("code"), 0o644))
72+
73+
checksum, err := ComputeChecksum(dir)
74+
require.NoError(t, err)
75+
76+
match, err := VerifyChecksum(dir, checksum)
77+
require.NoError(t, err)
78+
assert.True(t, match)
79+
80+
match, err = VerifyChecksum(dir, "wrong")
81+
require.NoError(t, err)
82+
assert.False(t, match)
83+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package state
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
const StateFileName = ".app_state"
12+
13+
// LoadState loads project state from work directory
14+
func LoadState(workDir string) (*ProjectState, error) {
15+
statePath := filepath.Join(workDir, StateFileName)
16+
17+
data, err := os.ReadFile(statePath)
18+
if err != nil {
19+
if errors.Is(err, os.ErrNotExist) {
20+
return nil, nil
21+
}
22+
return nil, fmt.Errorf("failed to read state file: %w", err)
23+
}
24+
25+
var state ProjectState
26+
if err := json.Unmarshal(data, &state); err != nil {
27+
return nil, fmt.Errorf("failed to parse state file: %w", err)
28+
}
29+
30+
return &state, nil
31+
}
32+
33+
// SaveState saves project state to work directory atomically
34+
func SaveState(workDir string, state *ProjectState) error {
35+
statePath := filepath.Join(workDir, StateFileName)
36+
tempPath := statePath + ".tmp"
37+
38+
data, err := json.MarshalIndent(state, "", " ")
39+
if err != nil {
40+
return fmt.Errorf("failed to serialize state: %w", err)
41+
}
42+
43+
if err := os.WriteFile(tempPath, data, 0o644); err != nil {
44+
return fmt.Errorf("failed to write temp state file: %w", err)
45+
}
46+
47+
if err := os.Rename(tempPath, statePath); err != nil {
48+
os.Remove(tempPath)
49+
return fmt.Errorf("failed to rename temp state file: %w", err)
50+
}
51+
52+
return nil
53+
}

0 commit comments

Comments
 (0)