V0.38.2 library packages (#235)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-25 14:09:05 +00:00
committed by xcaliber
parent 6943c91f40
commit 2136535176
22 changed files with 1233 additions and 6 deletions

View File

@@ -13,8 +13,10 @@ import (
"strings"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"chat-switchboard/database"
"chat-switchboard/models"
"chat-switchboard/sandbox"
"chat-switchboard/store"
)
@@ -29,6 +31,7 @@ type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
runner *sandbox.Runner // v0.38.2: for test-tool execution
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
h.sandbox = sb
}
// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2).
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
h.runner = r
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
@@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
return
}
// v0.38.2: Libraries with active consumers cannot be uninstalled.
if pkg.Type == "library" && h.stores.Dependencies != nil {
hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
if hasConsumers {
c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
return
}
}
// v0.38.2: Clean up dependency records when uninstalling a consumer.
if h.stores.Dependencies != nil {
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
}
// v0.29.2: Drop namespaced DB tables before removing the package record.
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
@@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
return
}
@@ -323,6 +345,21 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
return
}
case "library":
// v0.38.2: Libraries must declare exports, cannot have tools/pipes/route.
exports, _ := manifest["exports"].([]any)
if len(exports) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
return
}
if hasTools || hasPipes {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
return
}
}
// Check for conflicts with core packages
@@ -470,6 +507,47 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// v0.38.2: Create dependency records from manifest "dependencies" map.
// Libraries must be installed first; consumers declare them.
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
// Clear stale dependency records from a previous install of the same consumer.
if h.stores.Dependencies != nil {
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
}
for libID, vSpec := range deps {
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
if err != nil || lib == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
return
}
if lib.Type != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
return
}
if lib.Status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
return
}
versionSpec, _ := vSpec.(string)
if versionSpec == "" {
versionSpec = ">=0.0.0"
}
if h.stores.Dependencies != nil {
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
ConsumerID: pkgID,
LibraryID: libID,
VersionSpec: versionSpec,
ResolvedVer: lib.Version,
}); err != nil {
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
return
}
}
}
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
@@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": enabled})
}
// ── Test Tool (v0.38.2) ─────────────────────────
// POST /admin/packages/:id/test-tool
// Invokes a starlark extension's on_tool_call entry point directly,
// without going through the AI chat loop. Admin-only test harness.
// TestToolRequest is the JSON body for test-tool invocation.
type TestToolRequest struct {
ToolName string `json:"tool_name" binding:"required"`
Arguments map[string]any `json:"arguments"`
}
// TestTool invokes a package's on_tool_call entry point for testing.
func (h *PackageHandler) TestTool(c *gin.Context) {
if h.runner == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
return
}
pkgID := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
if pkg.Tier != "starlark" {
c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
return
}
if !pkg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
return
}
var req TestToolRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build a call dict identical to what executeExtensionTool sends.
// Build the arguments as an interface{} map for jsonToStarlark.
var args interface{}
if req.Arguments != nil {
argsJSON, _ := json.Marshal(req.Arguments)
_ = json.Unmarshal(argsJSON, &args)
}
callDict := starlark.NewDict(3)
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
userID, _ := c.Get("user_id")
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
val, output, err := h.runner.CallEntryPoint(
c.Request.Context(), pkg, "on_tool_call",
starlark.Tuple{callDict}, nil, rc,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
"output": output,
})
return
}
// Serialize the Starlark return value to Go types.
result := starlarkValueToGo(val)
c.JSON(http.StatusOK, gin.H{
"result": result,
"output": output,
})
}