From 72b0d4b1ab2dfe6182c7341e5dfe3415de6debe9 Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Fri, 8 May 2026 08:52:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20workspace=5Fplugins=20tracking?= =?UTF-8?q?=20table=20=E2=80=94=20version-subscription=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes core#113 partial. Adds the DB foundation for the version-subscription model. Drift detection + queue + admin apply endpoint are follow-up scope (separate PR; filed as a new issue). WHY THIS PR ONLY GETS US PART-WAY Plugin install state today is filesystem-only — '/configs/plugins//' inside the container. There's no DB record of 'plugin X installed at workspace W from source S, tracking ref T'. That makes drift detection impossible: nothing to compare upstream tags against. This PR adds the table + the install-endpoint hook that writes to it. With baseline tags now on every plugin (post internal#92), the table starts collecting tracked-ref values immediately on the next install. The actual drift-check job + queue + apply endpoint layer on top. WHAT THIS ADDS workspace_plugins table: workspace_id FK → workspaces(id) ON DELETE CASCADE plugin_name canonical name from plugin.yaml source_raw full source URL the install used tracked_ref 'none' | 'tag:vX.Y.Z' | 'tag:latest' | 'sha:' installed_at, updated_at installRequest gains optional 'track' field (defaults to 'none'). Install handler upserts the workspace_plugins row after delivery succeeds. DB write failure is logged but doesn't fail the install (the plugin IS in the container; surfacing 500 misleads the caller). validateTrackedRef enforces the closed set of accepted shapes: 'none' | 'tag:' | 'sha:' Bare values like 'latest' / 'main' / version-strings without prefix are rejected — the drift detector keys on prefix to know what kind of resolution to do. WHAT THIS DOES NOT ADD (filed separately) - Drift detector job (cron / on-demand) that scans 'WHERE tracked_ref != none' rows and queues updates on upstream drift - plugin_update_queue table (separate migration once detector lands) - GET /admin/plugin-updates-pending and POST .../apply endpoints - Tier-aware apply (core#115 — composes here) PHASE 4 SELF-REVIEW (FIVE-AXIS) Correctness: No finding — install endpoint behavior unchanged for callers that don't pass 'track'. DB write is best-effort + logged on failure. validateTrackedRef rejects ambiguous bare strings. Readability: No finding — separate file plugins_tracking.go isolates the new concern; install handler delta is a single 4-line block. Architecture: No finding — additive table; existing schema untouched. Migration 20260508160000_* uses the timestamp-prefixed convention. Security: No finding — INSERT params via placeholders (no string interpolation). validateTrackedRef rejects unexpected shapes before the column constraint would. Performance: No finding — one extra ExecContext per install. Install is already seconds-scale (network fetch + tar + docker exec); rounds to noise. TESTS (1 new, all green) TestValidateTrackedRef — pin closed set + structural validators REFS core#113 — this issue (foundation only; drift+queue+apply = follow-up) internal#92, internal#93 — plugin/template baseline tags (now exists for tracking) core#114 — atomic install (this PR composes — no atomicity regression) core#115 — canary tier filter (will key off the same DB foundation) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/plugins_install.go | 8 ++ .../handlers/plugins_install_pipeline.go | 9 +++ .../internal/handlers/plugins_tracking.go | 78 +++++++++++++++++++ .../handlers/plugins_tracking_test.go | 54 +++++++++++++ ...160000_workspace_plugins_tracking.down.sql | 3 + ...08160000_workspace_plugins_tracking.up.sql | 39 ++++++++++ 6 files changed, 191 insertions(+) create mode 100644 workspace-server/internal/handlers/plugins_tracking.go create mode 100644 workspace-server/internal/handlers/plugins_tracking_test.go create mode 100644 workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql create mode 100644 workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql diff --git a/workspace-server/internal/handlers/plugins_install.go b/workspace-server/internal/handlers/plugins_install.go index b0031bfb..32232727 100644 --- a/workspace-server/internal/handlers/plugins_install.go +++ b/workspace-server/internal/handlers/plugins_install.go @@ -91,6 +91,14 @@ func (h *PluginsHandler) Install(c *gin.Context) { return } + // Record the install in workspace_plugins (core#113 — version-subscription + // foundation). Best-effort: DB write failure is logged but doesn't fail + // the install — the plugin IS in the container; surfacing a 500 here + // would mislead the caller about the install state. + if err := recordWorkspacePluginInstall(ctx, workspaceID, result.PluginName, result.Source.Raw(), req.Track); err != nil { + log.Printf("Plugin install: failed to record %s for %s in workspace_plugins: %v (install succeeded; tracking row missing)", result.PluginName, workspaceID, err) + } + log.Printf("Plugin install: %s via %s → workspace %s (restarting)", result.PluginName, result.Source.Scheme, workspaceID) c.JSON(http.StatusOK, gin.H{ "status": "installed", diff --git a/workspace-server/internal/handlers/plugins_install_pipeline.go b/workspace-server/internal/handlers/plugins_install_pipeline.go index 8062e78f..31d1239e 100644 --- a/workspace-server/internal/handlers/plugins_install_pipeline.go +++ b/workspace-server/internal/handlers/plugins_install_pipeline.go @@ -114,6 +114,15 @@ type installRequest struct { // When present, resolveAndStage verifies the fetched content matches // before allowing the install to proceed (SAFE-T1102 supply-chain hardening). SHA256 string `json:"sha256,omitempty"` + // Track is the version-subscription mode for this install (core#113): + // "none" — no auto-update tracking (default) + // "tag:vX.Y.Z" — track a specific version tag + // "tag:latest" — track latest tag, drift on every new tag + // "sha:" — pinned, no drift ever + // The drift detector (separate component, follow-up) reads + // workspace_plugins rows where tracked_ref != 'none' and queues + // updates when upstream resolves to a different SHA. + Track string `json:"track,omitempty"` } // stageResult bundles the outputs of resolveAndStage for the caller. diff --git a/workspace-server/internal/handlers/plugins_tracking.go b/workspace-server/internal/handlers/plugins_tracking.go new file mode 100644 index 00000000..56831a06 --- /dev/null +++ b/workspace-server/internal/handlers/plugins_tracking.go @@ -0,0 +1,78 @@ +package handlers + +// plugins_tracking.go — workspace_plugins DB tracking for the +// version-subscription model (core#113). +// +// Schema lives in migration 20260508160000_workspace_plugins_tracking.up.sql. +// This file is the Go-side write surface used at install time to record +// each plugin's install record. Drift detection / queue / apply are +// follow-up scope (filed as a separate issue once this lands). + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// trackedRefValues is the closed set of bare-string values the +// workspace_plugins.tracked_ref column accepts. Prefixed values +// ("tag:..." / "sha:...") are validated structurally below. +var trackedRefValues = map[string]bool{ + "none": true, +} + +// validateTrackedRef returns the canonical form of a track string, or +// an error if the input is malformed. Empty input → "none" (default). +// +// Accepted shapes: +// +// "" — defaults to "none" +// "none" — no tracking +// "tag:vX.Y.Z" — track a specific tag +// "tag:latest" — track latest tag, drift on every new tag +// "sha:" — pinned to commit SHA +func validateTrackedRef(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "none", nil + } + if trackedRefValues[s] { + return s, nil + } + if strings.HasPrefix(s, "tag:") && len(s) > 4 { + return s, nil + } + if strings.HasPrefix(s, "sha:") && len(s) > 4 { + return s, nil + } + return "", fmt.Errorf("invalid track value %q: expected 'none' | 'tag:vX.Y.Z' | 'tag:latest' | 'sha:'", s) +} + +// recordWorkspacePluginInstall upserts the workspace_plugins row for a +// plugin install. ON CONFLICT (workspace_id, plugin_name) DO UPDATE so +// reinstalling the same plugin name (with a possibly-different source or +// track value) updates the existing row rather than failing. +func recordWorkspacePluginInstall( + ctx context.Context, workspaceID, pluginName, sourceRaw, track string, +) error { + if workspaceID == "" || pluginName == "" || sourceRaw == "" { + return errors.New("recordWorkspacePluginInstall: missing required field") + } + canonicalTrack, err := validateTrackedRef(track) + if err != nil { + return err + } + _, err = db.DB.ExecContext(ctx, ` + INSERT INTO workspace_plugins (workspace_id, plugin_name, source_raw, tracked_ref) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, plugin_name) + DO UPDATE SET + source_raw = EXCLUDED.source_raw, + tracked_ref = EXCLUDED.tracked_ref, + updated_at = NOW() + `, workspaceID, pluginName, sourceRaw, canonicalTrack) + return err +} diff --git a/workspace-server/internal/handlers/plugins_tracking_test.go b/workspace-server/internal/handlers/plugins_tracking_test.go new file mode 100644 index 00000000..8c33023d --- /dev/null +++ b/workspace-server/internal/handlers/plugins_tracking_test.go @@ -0,0 +1,54 @@ +package handlers + +import "testing" + +// TestValidateTrackedRef: pin the exact set of accepted track values +// the install endpoint stores. Drift detector reads this column; any +// value that slips through here without structural validation would +// silently fail at drift-check time. +func TestValidateTrackedRef(t *testing.T) { + cases := []struct { + in string + want string + err bool + }{ + // Defaults + {"", "none", false}, + {" ", "none", false}, + {"none", "none", false}, + + // Tag shape + {"tag:v1.0.0", "tag:v1.0.0", false}, + {"tag:v0.4.0-gitea.1", "tag:v0.4.0-gitea.1", false}, + {"tag:latest", "tag:latest", false}, + + // SHA shape + {"sha:abc123", "sha:abc123", false}, + {"sha:0123456789abcdef0123456789abcdef01234567", "sha:0123456789abcdef0123456789abcdef01234567", false}, + + // Reject malformed + {"tag:", "", true}, // empty after prefix + {"sha:", "", true}, // empty after prefix + {"latest", "", true}, // bare 'latest' is ambiguous (tag? branch?) + {"main", "", true}, // bare branch name not allowed + {"v1.0.0", "", true}, // missing tag: prefix + {"random", "", true}, // not in allowlist + {"tag", "", true}, // prefix without separator + } + for _, tc := range cases { + got, err := validateTrackedRef(tc.in) + if tc.err { + if err == nil { + t.Errorf("validateTrackedRef(%q) = (%q, nil); want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("validateTrackedRef(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("validateTrackedRef(%q) = %q; want %q", tc.in, got, tc.want) + } + } +} diff --git a/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql new file mode 100644 index 00000000..9beee5cf --- /dev/null +++ b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS workspace_plugins_tracked_not_none; +DROP INDEX IF EXISTS workspace_plugins_workspace_name; +DROP TABLE IF EXISTS workspace_plugins; diff --git a/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql new file mode 100644 index 00000000..a6164832 --- /dev/null +++ b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql @@ -0,0 +1,39 @@ +-- workspace_plugins: per-workspace record of installed plugins, with the +-- tracked-ref needed for the version-subscription model (core#113). +-- +-- Today plugin install state is filesystem-only — `/configs/plugins//` +-- inside the workspace container. There's no DB record of "what's installed +-- where, from what source, pinned to what." That's fine until you want +-- drift detection (compare upstream tag's resolved SHA vs the installed +-- one) and that's the foundation this table provides. +-- +-- This migration is purely additive: existing install paths keep working; +-- they'll write to this table on next install. Workspaces with plugins +-- already installed before this migration won't have rows until they're +-- re-installed (acceptable — the tracking is forward-looking). +-- +-- tracked_ref values: +-- 'none' — no auto-update tracking (default) +-- 'tag:vX.Y.Z' — track a specific version tag +-- 'tag:latest' — track the latest tag (drift on every new tag) +-- 'sha:' — pinned to a specific commit SHA (no drift ever) +-- +-- A subsequent migration adds the plugin_update_queue table once drift +-- detection lands. + +CREATE TABLE IF NOT EXISTS workspace_plugins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + plugin_name TEXT NOT NULL, + source_raw TEXT NOT NULL, + tracked_ref TEXT NOT NULL DEFAULT 'none', + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS workspace_plugins_workspace_name + ON workspace_plugins(workspace_id, plugin_name); + +-- Partial index for the drift detector: only scan rows opted into tracking. +CREATE INDEX IF NOT EXISTS workspace_plugins_tracked_not_none + ON workspace_plugins(tracked_ref) WHERE tracked_ref != 'none'; -- 2.45.2