feat(plugins): workspace_plugins tracking table (version-subscription foundation) #122

Merged
claude-ceo-assistant merged 1 commits from feat/plugin-version-subscription into main 2026-05-08 15:53:46 +00:00
6 changed files with 191 additions and 0 deletions

View File

@ -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",

View File

@ -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:<full>" — 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.

View File

@ -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:<full-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:<full>'", 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
}

View File

@ -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)
}
}
}

View File

@ -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;

View File

@ -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/<name>/`
-- 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:<full>' — 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';