diff --git a/workspace-server/internal/handlers/instructions.go b/workspace-server/internal/handlers/instructions.go new file mode 100644 index 00000000..41dcd8e3 --- /dev/null +++ b/workspace-server/internal/handlers/instructions.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/gin-gonic/gin" +) + +type InstructionsHandler struct{} + +func NewInstructionsHandler() *InstructionsHandler { + return &InstructionsHandler{} +} + +type Instruction struct { + ID string `json:"id"` + Scope string `json:"scope"` + ScopeTarget *string `json:"scope_target"` + Title string `json:"title"` + Content string `json:"content"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// List returns instructions filtered by scope. Agents call this at startup +// to fetch their full instruction set (global + team + workspace). +// +// GET /instructions?scope=global +// GET /instructions?workspace_id= (returns global + team + workspace) +func (h *InstructionsHandler) List(c *gin.Context) { + ctx := c.Request.Context() + scope := c.Query("scope") + workspaceID := c.Query("workspace_id") + + var rows_ interface{ Close() error } + var err error + + if workspaceID != "" { + // Agent bootstrap: fetch all applicable instructions (global + team + workspace) + // ordered by scope priority (global first) then user priority descending. + var teamSlug *string + db.DB.QueryRowContext(ctx, + `SELECT t.slug FROM teams t + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.workspace_id = $1 LIMIT 1`, workspaceID).Scan(&teamSlug) + + query := `SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at + FROM platform_instructions + WHERE enabled = true AND ( + scope = 'global' + OR (scope = 'team' AND scope_target = $1) + OR (scope = 'workspace' AND scope_target = $2) + ) + ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'workspace' THEN 2 END, + priority DESC` + + teamTarget := "" + if teamSlug != nil { + teamTarget = *teamSlug + } + r, qErr := db.DB.QueryContext(ctx, query, teamTarget, workspaceID) + if qErr != nil { + log.Printf("Instructions list error: %v", qErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + rows_ = r + defer r.Close() + instructions := scanInstructions(r) + c.JSON(http.StatusOK, instructions) + return + } + + // Admin listing by scope + query := `SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at + FROM platform_instructions WHERE 1=1` + args := []interface{}{} + if scope != "" { + query += ` AND scope = $1` + args = append(args, scope) + } + query += ` ORDER BY scope, priority DESC, created_at` + + r, qErr := db.DB.QueryContext(ctx, query, args...) + if qErr != nil { + log.Printf("Instructions list error: %v", qErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + rows_ = r + _ = rows_ + defer r.Close() + c.JSON(http.StatusOK, scanInstructions(r)) +} + +// Create adds a new platform instruction. +// POST /instructions +func (h *InstructionsHandler) Create(c *gin.Context) { + var body struct { + Scope string `json:"scope" binding:"required"` + ScopeTarget *string `json:"scope_target"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Priority int `json:"priority"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "scope, title, and content are required"}) + return + } + if body.Scope != "global" && body.Scope != "team" && body.Scope != "workspace" { + c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be global, team, or workspace"}) + return + } + if body.Scope != "global" && (body.ScopeTarget == nil || *body.ScopeTarget == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "scope_target required for team/workspace scope"}) + return + } + + var id string + err := db.DB.QueryRowContext(c.Request.Context(), + `INSERT INTO platform_instructions (scope, scope_target, title, content, priority) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + body.Scope, body.ScopeTarget, body.Title, body.Content, body.Priority, + ).Scan(&id) + if err != nil { + log.Printf("Instructions create error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "insert failed"}) + return + } + c.JSON(http.StatusCreated, gin.H{"id": id}) +} + +// Update modifies an existing instruction. +// PUT /instructions/:id +func (h *InstructionsHandler) Update(c *gin.Context) { + id := c.Param("id") + var body struct { + Title *string `json:"title"` + Content *string `json:"content"` + Priority *int `json:"priority"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + result, err := db.DB.ExecContext(c.Request.Context(), + `UPDATE platform_instructions SET + title = COALESCE($2, title), + content = COALESCE($3, content), + priority = COALESCE($4, priority), + enabled = COALESCE($5, enabled), + updated_at = NOW() + WHERE id = $1`, + id, body.Title, body.Content, body.Priority, body.Enabled, + ) + if err != nil { + log.Printf("Instructions update error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) + return + } + if n, _ := result.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "instruction not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} + +// Delete removes an instruction. +// DELETE /instructions/:id +func (h *InstructionsHandler) Delete(c *gin.Context) { + id := c.Param("id") + result, err := db.DB.ExecContext(c.Request.Context(), + `DELETE FROM platform_instructions WHERE id = $1`, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + if n, _ := result.RowsAffected(); n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "instruction not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +// Resolve returns the merged instruction text for a workspace — all enabled +// instructions across global → team → workspace scope, concatenated in order. +// This is what the Python runtime calls to get the full instruction set. +// +// GET /instructions/resolve?workspace_id= +func (h *InstructionsHandler) Resolve(c *gin.Context) { + workspaceID := c.Query("workspace_id") + if workspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id required"}) + return + } + ctx := c.Request.Context() + + var teamSlug string + db.DB.QueryRowContext(ctx, + `SELECT COALESCE(t.slug, '') FROM teams t + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.workspace_id = $1 LIMIT 1`, workspaceID).Scan(&teamSlug) + + rows, err := db.DB.QueryContext(ctx, + `SELECT scope, title, content FROM platform_instructions + WHERE enabled = true AND ( + scope = 'global' + OR (scope = 'team' AND scope_target = $1) + OR (scope = 'workspace' AND scope_target = $2) + ) + ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'workspace' THEN 2 END, + priority DESC`, + teamSlug, workspaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + defer rows.Close() + + var merged string + currentScope := "" + for rows.Next() { + var scope, title, content string + if err := rows.Scan(&scope, &title, &content); err != nil { + continue + } + if scope != currentScope { + scopeLabel := map[string]string{ + "global": "Platform-Wide Rules", + "team": "Team Rules", + "workspace": "Role-Specific Rules", + }[scope] + merged += "\n## " + scopeLabel + "\n\n" + currentScope = scope + } + merged += "### " + title + "\n" + content + "\n\n" + } + + c.JSON(http.StatusOK, gin.H{ + "workspace_id": workspaceID, + "team_slug": teamSlug, + "instructions": merged, + }) +} + +func scanInstructions(rows interface { + Next() bool + Scan(dest ...interface{}) error +}) []Instruction { + var instructions []Instruction + for rows.Next() { + var inst Instruction + if err := rows.Scan(&inst.ID, &inst.Scope, &inst.ScopeTarget, &inst.Title, + &inst.Content, &inst.Priority, &inst.Enabled, &inst.CreatedAt, &inst.UpdatedAt); err != nil { + log.Printf("Instructions scan error: %v", err) + continue + } + instructions = append(instructions, inst) + } + if instructions == nil { + instructions = []Instruction{} + } + return instructions +} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index b9610fd6..674231ed 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -364,6 +364,19 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi adminAuth.DELETE("/admin/secrets/:key", sechGlobal.DeleteGlobal) } + // Platform instructions — configurable rules with global/team/workspace scope. + // Admin endpoints for CRUD; workspace-facing resolve endpoint for agent bootstrap. + { + instrH := handlers.NewInstructionsHandler() + adminInstr := r.Group("", middleware.AdminAuth(db.DB)) + adminInstr.GET("/instructions", instrH.List) + adminInstr.POST("/instructions", instrH.Create) + adminInstr.PUT("/instructions/:id", instrH.Update) + adminInstr.DELETE("/instructions/:id", instrH.Delete) + // Resolve endpoint is open to workspace auth (agents call it at startup) + r.GET("/instructions/resolve", instrH.Resolve) + } + // Admin — cross-workspace schedule health monitoring (issue #618). // Lets cron-audit agents and operators detect silent schedule failures // across all workspaces without holding individual workspace bearer tokens. diff --git a/workspace-server/migrations/040_platform_instructions.down.sql b/workspace-server/migrations/040_platform_instructions.down.sql new file mode 100644 index 00000000..acebc56f --- /dev/null +++ b/workspace-server/migrations/040_platform_instructions.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_platform_instructions_scope; +DROP TABLE IF EXISTS platform_instructions; diff --git a/workspace-server/migrations/040_platform_instructions.up.sql b/workspace-server/migrations/040_platform_instructions.up.sql new file mode 100644 index 00000000..ebf812fe --- /dev/null +++ b/workspace-server/migrations/040_platform_instructions.up.sql @@ -0,0 +1,18 @@ +-- Platform-level configurable instructions with global/team/workspace scope. +-- Injected into every agent's system prompt at startup and refreshed +-- periodically, so platform operators can enforce rules without editing +-- template files. +CREATE TABLE IF NOT EXISTS platform_instructions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'workspace')), + scope_target TEXT, -- NULL for global, team slug for team, workspace_id for workspace + title TEXT NOT NULL, + content TEXT NOT NULL, + priority INT DEFAULT 0, -- higher = shown first within scope + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_platform_instructions_scope + ON platform_instructions (scope, scope_target) WHERE enabled = true; diff --git a/workspace/adapter_base.py b/workspace/adapter_base.py index 0de914c4..8cb5cb8d 100644 --- a/workspace/adapter_base.py +++ b/workspace/adapter_base.py @@ -294,7 +294,7 @@ class BaseAdapter(ABC): from plugins import load_plugins from skill_loader.loader import load_skills from coordinator import get_children, get_parent_context, build_children_description - from prompt import build_system_prompt, get_peer_capabilities + from prompt import build_system_prompt, get_peer_capabilities, get_platform_instructions from builtin_tools.approval import request_approval from builtin_tools.delegation import delegate_to_workspace, check_delegation_status from builtin_tools.memory import commit_memory, search_memory @@ -344,6 +344,7 @@ class BaseAdapter(ABC): # Build system prompt with all context peers = await get_peer_capabilities(platform_url, config.workspace_id) + platform_instructions = await get_platform_instructions(platform_url, config.workspace_id) coordinator_prompt = build_children_description(children) if is_coordinator else "" extra_prompts = list(plugins.prompt_fragments) if coordinator_prompt: @@ -355,6 +356,7 @@ class BaseAdapter(ABC): plugin_rules=plugins.rules, plugin_prompts=extra_prompts, parent_context=parent_context, + platform_instructions=platform_instructions, ) return SetupResult( diff --git a/workspace/prompt.py b/workspace/prompt.py index 33de1265..818ec182 100644 --- a/workspace/prompt.py +++ b/workspace/prompt.py @@ -1,5 +1,6 @@ """Build the system prompt for the workspace agent.""" +import os from pathlib import Path from skill_loader.loader import LoadedSkill @@ -25,6 +26,24 @@ async def get_peer_capabilities(platform_url: str, workspace_id: str) -> list[di return [] +async def get_platform_instructions(platform_url: str, workspace_id: str) -> str: + """Fetch resolved platform instructions (global + team + workspace scope).""" + try: + import httpx + + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{platform_url}/instructions/resolve", + params={"workspace_id": workspace_id}, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("instructions", "") + except Exception as e: + print(f"Warning: could not fetch platform instructions: {e}") + return "" + + def build_system_prompt( config_path: str, workspace_id: str, @@ -34,6 +53,7 @@ def build_system_prompt( plugin_rules: list[str] | None = None, plugin_prompts: list[str] | None = None, parent_context: list[dict] | None = None, + platform_instructions: str = "", ) -> str: """Build the complete system prompt. @@ -50,6 +70,12 @@ def build_system_prompt( """ parts = [] + # Platform instructions (global → team → workspace scope) go first so + # they take highest precedence in the context window. + if platform_instructions: + parts.append("# Platform Instructions\n") + parts.append(platform_instructions) + # Load prompt files in order files_to_load = list(prompt_files or []) if not files_to_load: