From 792036afc29770808c53ef31b086413a88ad60df Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Fri, 26 Jun 2026 10:25:12 +0200 Subject: [PATCH 1/3] aitools: state schema v2 with per-agent plugin + file provenance records Adds the .state.json schema v2 the plugin-first redesign needs, additively: - PluginRecord (keyed by agent name in InstallState.Plugins): the plugin + marketplace installed through an agent's own CLI, its native scope, last seen version, and whether the CLI registered the marketplace (so uninstall knows whether it may de-register it). - FileRecord (InstallState.Files, keyed by canonical-relative path): per-file sha256 + origin, so a later update can prune a vanished skill only when the on-disk file still matches what we wrote. Both maps are json:omitempty, so existing files-only state serializes byte-identically. LoadState runs a forward-only, idempotent migrateState that stamps v1 state as v2 (no data transformation needed; the maps are additive and writers lazily init them, as RepoDirs already does). The fresh-install writer now stamps v2 so on-disk and in-memory versions agree. Co-authored-by: Isaac --- libs/aitools/installer/installer.go | 2 +- libs/aitools/installer/installer_test.go | 2 +- libs/aitools/installer/state.go | 53 +++++++++++++++++++++++ libs/aitools/installer/state_test.go | 55 +++++++++++++++++++++++- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/libs/aitools/installer/installer.go b/libs/aitools/installer/installer.go index d2caca5af0f..aefaca32f7a 100644 --- a/libs/aitools/installer/installer.go +++ b/libs/aitools/installer/installer.go @@ -272,7 +272,7 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent // previous installs (e.g., experimental skills from a prior run) are preserved. if state == nil { state = &InstallState{ - SchemaVersion: 1, + SchemaVersion: schemaVersionV2, Skills: make(map[string]string, len(targetSkills)), RepoDirs: make(map[string]string, len(targetSkills)), } diff --git a/libs/aitools/installer/installer_test.go b/libs/aitools/installer/installer_test.go index ad7c1810e98..66e074d08af 100644 --- a/libs/aitools/installer/installer_test.go +++ b/libs/aitools/installer/installer_test.go @@ -317,7 +317,7 @@ func TestInstallSkillsForAgentsWritesState(t *testing.T) { state, err := LoadState(globalDir) require.NoError(t, err) require.NotNil(t, state) - assert.Equal(t, 1, state.SchemaVersion) + assert.Equal(t, schemaVersionV2, state.SchemaVersion) assert.Equal(t, testSkillsRef, state.Release) assert.Len(t, state.Skills, 2) assert.Equal(t, "0.1.0", state.Skills["databricks-sql"]) diff --git a/libs/aitools/installer/state.go b/libs/aitools/installer/state.go index 9aa52359bd5..b1a06a5ffb8 100644 --- a/libs/aitools/installer/state.go +++ b/libs/aitools/installer/state.go @@ -14,6 +14,10 @@ import ( const stateFileName = ".state.json" +// schemaVersionV2 is the current on-disk state schema version. v2 adds the +// additive Plugins and Files maps; v1 state loads forward without data changes. +const schemaVersionV2 = 2 + // Scope constants for skill installation. const ( ScopeGlobal = "global" @@ -29,6 +33,44 @@ type InstallState struct { Skills map[string]string `json:"skills"` RepoDirs map[string]string `json:"repo_dirs,omitempty"` Scope string `json:"scope,omitempty"` + + // Plugins records databricks plugins installed through an agent's own CLI, + // keyed by registry agent name (e.g. "claude-code"). Added in schema v2; + // omitted for files-only installs. + Plugins map[string]PluginRecord `json:"plugins,omitempty"` + // Files records provenance for skill files the CLI wrote, keyed by the + // file path relative to the scope's canonical skills dir (forward slashes, + // e.g. "databricks/SKILL.md"). Added in schema v2; used to prune only the + // skills we wrote that the user hasn't modified. + Files map[string]FileRecord `json:"files,omitempty"` +} + +// PluginRecord records a databricks plugin installed for an agent through the +// agent's own plugin CLI, so update/uninstall act on exactly where we +// installed and list/version can report real plugin state. +type PluginRecord struct { + // Marketplace is the marketplace registry name the plugin was installed from. + Marketplace string `json:"marketplace"` + // Plugin is the installed plugin id (e.g. "databricks"). + Plugin string `json:"plugin"` + // Scope is the agent-native install scope (e.g. "user" or "project"). + Scope string `json:"scope,omitempty"` + // Version is the last seen plugin/release version, when known. + Version string `json:"version,omitempty"` + // InstalledMarketplace is true when this CLI registered the marketplace, + // so uninstall may de-register it. False when it was already present and we + // must leave it for whatever else shares it. + InstalledMarketplace bool `json:"installed_marketplace,omitempty"` +} + +// FileRecord records provenance for a single skill file the CLI wrote, so +// update can prune a vanished skill only when the on-disk file still matches +// what we wrote (i.e. the user hasn't modified it). +type FileRecord struct { + // SHA256 is the hex-encoded checksum of the file content the CLI wrote. + SHA256 string `json:"sha256"` + // Origin is the skills ref the file was fetched from, when known. + Origin string `json:"origin,omitempty"` } // LoadState reads install state from the given directory. @@ -46,9 +88,20 @@ func LoadState(dir string) (*InstallState, error) { if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("failed to parse state file: %w", err) } + migrateState(&state) return &state, nil } +// migrateState brings a loaded state forward to the current schema version. It +// is forward-only and idempotent. v1 -> v2 needs no data transformation (the +// Plugins/Files maps are additive and optional), so it only stamps the version; +// writers lazily initialize the maps, matching how RepoDirs is handled. +func migrateState(state *InstallState) { + if state.SchemaVersion < schemaVersionV2 { + state.SchemaVersion = schemaVersionV2 + } +} + // SaveState writes install state to the given directory atomically. // Creates the directory if it does not exist. func SaveState(dir string, state *InstallState) error { diff --git a/libs/aitools/installer/state_test.go b/libs/aitools/installer/state_test.go index 4ed1e78d5f2..0dd9987bcac 100644 --- a/libs/aitools/installer/state_test.go +++ b/libs/aitools/installer/state_test.go @@ -20,7 +20,7 @@ func TestLoadStateNonexistentFile(t *testing.T) { func TestSaveAndLoadStateRoundtrip(t *testing.T) { dir := t.TempDir() original := &InstallState{ - SchemaVersion: 1, + SchemaVersion: schemaVersionV2, Release: "v0.2.0", LastUpdated: time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC), Skills: map[string]string{ @@ -97,10 +97,61 @@ func TestProjectSkillsDirReturnsCwdBased(t *testing.T) { assert.Equal(t, filepath.Join(cwd, ".databricks", "aitools", "skills"), dir) } +func TestLoadStateMigratesV1ToV2(t *testing.T) { + dir := t.TempDir() + // A v1 state on disk has no plugins/files keys. + v1 := `{"schema_version":1,"release":"v0.2.0","last_updated":"2026-03-22T10:00:00Z","skills":{"databricks":"1.0.0"},"repo_dirs":{"databricks":"skills"}}` + require.NoError(t, os.WriteFile(filepath.Join(dir, stateFileName), []byte(v1), 0o644)) + + loaded, err := LoadState(dir) + require.NoError(t, err) + assert.Equal(t, schemaVersionV2, loaded.SchemaVersion) + // Migration is additive: existing data is untouched and the new maps stay nil. + assert.Equal(t, map[string]string{"databricks": "1.0.0"}, loaded.Skills) + assert.Nil(t, loaded.Plugins) + assert.Nil(t, loaded.Files) +} + +func TestMigrateStateIsIdempotent(t *testing.T) { + state := &InstallState{SchemaVersion: schemaVersionV2, Skills: map[string]string{"databricks": "1.0.0"}} + before := *state + migrateState(state) + assert.Equal(t, before, *state) +} + +func TestSaveAndLoadStateWithPluginAndFileRecords(t *testing.T) { + dir := t.TempDir() + original := &InstallState{ + SchemaVersion: schemaVersionV2, + Release: "v0.2.6", + LastUpdated: time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC), + Skills: map[string]string{"databricks": "1.0.0"}, + RepoDirs: map[string]string{"databricks": stableSkillsRepoPath}, + Plugins: map[string]PluginRecord{ + "claude-code": { + Marketplace: "databricks-agent-skills", + Plugin: "databricks", + Scope: "user", + Version: "0.2.6", + InstalledMarketplace: true, + }, + }, + Files: map[string]FileRecord{ + "databricks/SKILL.md": {SHA256: "abc123", Origin: "v0.2.6"}, + }, + } + + require.NoError(t, SaveState(dir, original)) + + loaded, err := LoadState(dir) + require.NoError(t, err) + assert.Equal(t, original, loaded) +} + func TestSaveAndLoadStateWithOptionalFields(t *testing.T) { dir := t.TempDir() original := &InstallState{ - SchemaVersion: 1, + SchemaVersion: schemaVersionV2, IncludeExperimental: true, Release: "v0.3.0", LastUpdated: time.Date(2026, 3, 22, 12, 30, 0, 0, time.UTC), From a1b4aba214853c78e90c5633d499324550e67b5f Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Fri, 26 Jun 2026 12:10:52 +0200 Subject: [PATCH 2/3] aitools: strengthen migrate idempotency test to start from v1 Address cursor review: the test only exercised an already-v2 state, so it would pass even if v1->v2 migration weren't idempotent. Start at v1 and migrate twice. Co-authored-by: Isaac --- libs/aitools/installer/state_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/libs/aitools/installer/state_test.go b/libs/aitools/installer/state_test.go index 0dd9987bcac..8650d19d27e 100644 --- a/libs/aitools/installer/state_test.go +++ b/libs/aitools/installer/state_test.go @@ -113,10 +113,15 @@ func TestLoadStateMigratesV1ToV2(t *testing.T) { } func TestMigrateStateIsIdempotent(t *testing.T) { - state := &InstallState{SchemaVersion: schemaVersionV2, Skills: map[string]string{"databricks": "1.0.0"}} - before := *state + // Start at v1 so this exercises the real v1 -> v2 migration, then confirm a + // second migrateState is a no-op. + state := &InstallState{SchemaVersion: 1, Skills: map[string]string{"databricks": "1.0.0"}} migrateState(state) - assert.Equal(t, before, *state) + assert.Equal(t, schemaVersionV2, state.SchemaVersion) + + migrated := *state + migrateState(state) + assert.Equal(t, migrated, *state) } func TestSaveAndLoadStateWithPluginAndFileRecords(t *testing.T) { From 78767ed30faf770db4ddb3c6f6f1f6e5adde9931 Mon Sep 17 00:00:00 2001 From: simonfaltum Date: Fri, 26 Jun 2026 14:13:04 +0200 Subject: [PATCH 3/3] aitools: rename Preselect to IsPreselected Addresses review nit on #5734: IsPreselected reads as a read-only predicate. Behavior unchanged; rename only. Co-authored-by: Isaac --- libs/aitools/agents/detect.go | 4 ++-- libs/aitools/agents/detect_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/aitools/agents/detect.go b/libs/aitools/agents/detect.go index 6aaa344b6ba..49d5f65ee3b 100644 --- a/libs/aitools/agents/detect.go +++ b/libs/aitools/agents/detect.go @@ -80,11 +80,11 @@ func (a *Agent) DisplayState(ctx context.Context) DisplayState { } } -// Preselect reports whether the agent should be pre-checked in the picker. Only +// IsPreselected reports whether the agent should be pre-checked in the picker. Only // agents that can complete an install automatically (a plugin agent with its // binary on PATH) and files-only agents whose config dir exists are pre-checked; // manual-only and binary-missing agents are shown but left unchecked. -func (a *Agent) Preselect(ctx context.Context) bool { +func (a *Agent) IsPreselected(ctx context.Context) bool { switch a.DisplayState(ctx) { case StateAvailable: return true diff --git a/libs/aitools/agents/detect_test.go b/libs/aitools/agents/detect_test.go index 1de6f26d79e..330d041f7b9 100644 --- a/libs/aitools/agents/detect_test.go +++ b/libs/aitools/agents/detect_test.go @@ -99,7 +99,7 @@ func TestDisplayState(t *testing.T) { } } -func TestPreselect(t *testing.T) { +func TestIsPreselected(t *testing.T) { ctx := t.Context() tests := []struct { @@ -122,7 +122,7 @@ func TestPreselect(t *testing.T) { t.Run(tc.name, func(t *testing.T) { stubLookPath(t, tc.onPath...) a := &Agent{Binary: tc.binary, Plugin: tc.plugin, ConfigDir: configDir(t, tc.hasCfg)} - assert.Equal(t, tc.expected, a.Preselect(ctx)) + assert.Equal(t, tc.expected, a.IsPreselected(ctx)) }) } }