Three parallel cleanups driven by the second code-review pass.
## Native dialogs → ConfirmDialog (7 sites)
Violated the standing feedback_no_native_dialogs rule.
- ChannelsTab: confirm() → ConfirmDialog danger variant with pendingDelete state
- ScheduleTab: window.confirm() → ConfirmDialog danger
- ChatTab: confirm("Restart...") → ConfirmDialog warning (restart is recoverable)
- TemplatePalette: two alert() sites collapsed into a single notice state +
ConfirmDialog as OK-only info toast
- ErrorBoundary: dropped both window.alert calls entirely. Clipboard-copy
click is self-evident; console.error already captures the fallback.
## .env.example ↔ Go env var sync
Added 11 previously-undocumented env vars grouped into 6 new sections:
- Platform: PLATFORM_URL, MOLECULE_URL, WORKSPACE_DIR, MOLECULE_ENV
- CORS / rate limiting: CORS_ORIGINS, RATE_LIMIT
- Activity retention: ACTIVITY_RETENTION_DAYS, ACTIVITY_CLEANUP_INTERVAL_HOURS
- Container detection: MOLECULE_IN_DOCKER (moved to dedup)
- Observability: AWARENESS_URL
- Webhooks: GITHUB_WEBHOOK_SECRET
- CLI: MOLECLI_URL
All 21 distinct os.Getenv / envx.* keys (excluding HOME) now documented.
Zero orphans in the other direction.
## Go handler function splits (4 funcs, pure refactor)
No behavior change; same tests pass.
| Function | Before | After | Helpers |
|---------------------------|-------:|------:|---------------------------------------------------------------|
| proxyA2ARequest | 257 | 56 | resolveAgentURL, normalizeA2APayload, dispatchA2A, |
| | | | handleA2ADispatchError, maybeMarkContainerDead, |
| | | | logA2AFailure, logA2ASuccess |
| Delegate | 127 | 60 | bindDelegateRequest, lookupIdempotentDelegation, |
| | | | insertDelegationRow |
| Discover | 125 | 40 | discoverWorkspacePeer, writeExternalWorkspaceURL, |
| | | | discoverHostPeer |
| SessionSearch | 109 | 24 | parseSessionSearchParams, buildSessionSearchQuery, |
| | | | scanSessionSearchRows |
Preserved exact error semantics, log.Printf calls, status codes, and
response shapes. Introduced a proxyDispatchBuildError sentinel in
a2a_proxy so the orchestrator can distinguish "couldn't build the
request" from "Do() failed" without changing existing branches.
## Verification
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/... — all pass
- canvas npm run build — clean
- canvas npm test -- --run — 352/352 pass
- grep window.confirm|window.alert|window.prompt in canvas/src — 0 matches
- every platform os.Getenv key present in .env.example
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
415 lines
12 KiB
Go
415 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type ActivityHandler struct {
|
|
broadcaster *events.Broadcaster
|
|
}
|
|
|
|
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
|
return &ActivityHandler{broadcaster: b}
|
|
}
|
|
|
|
// List handles GET /workspaces/:id/activity?type=&limit=
|
|
func (h *ActivityHandler) List(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
activityType := c.Query("type")
|
|
source := c.Query("source") // "canvas" = source_id IS NULL, "agent" = source_id IS NOT NULL
|
|
limitStr := c.DefaultQuery("limit", "100")
|
|
|
|
limit := 100
|
|
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
|
limit = n
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
}
|
|
|
|
// Build query with optional filters
|
|
query := `SELECT id, workspace_id, activity_type, source_id, target_id, method,
|
|
summary, request_body, response_body, duration_ms, status, error_detail, created_at
|
|
FROM activity_logs WHERE workspace_id = $1`
|
|
args := []interface{}{workspaceID}
|
|
argIdx := 2
|
|
|
|
if activityType != "" {
|
|
query += fmt.Sprintf(" AND activity_type = $%d", argIdx)
|
|
args = append(args, activityType)
|
|
argIdx++
|
|
}
|
|
if source == "canvas" {
|
|
query += " AND source_id IS NULL"
|
|
} else if source == "agent" {
|
|
query += " AND source_id IS NOT NULL"
|
|
} else if source != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'canvas' or 'agent'"})
|
|
return
|
|
}
|
|
|
|
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argIdx)
|
|
args = append(args, limit)
|
|
|
|
rows, err := db.DB.QueryContext(c.Request.Context(), query, args...)
|
|
|
|
if err != nil {
|
|
log.Printf("Activity list error for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
activities := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var id, wsID, actType, status string
|
|
var sourceID, targetID, method, summary, errorDetail *string
|
|
var reqBody, respBody []byte
|
|
var durationMs *int
|
|
var createdAt time.Time
|
|
|
|
if err := rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
|
|
&summary, &reqBody, &respBody, &durationMs, &status, &errorDetail, &createdAt); err != nil {
|
|
log.Printf("Activity scan error: %v", err)
|
|
continue
|
|
}
|
|
|
|
entry := map[string]interface{}{
|
|
"id": id,
|
|
"workspace_id": wsID,
|
|
"activity_type": actType,
|
|
"source_id": sourceID,
|
|
"target_id": targetID,
|
|
"method": method,
|
|
"summary": summary,
|
|
"duration_ms": durationMs,
|
|
"status": status,
|
|
"error_detail": errorDetail,
|
|
"created_at": createdAt,
|
|
}
|
|
if reqBody != nil {
|
|
entry["request_body"] = json.RawMessage(reqBody)
|
|
}
|
|
if respBody != nil {
|
|
entry["response_body"] = json.RawMessage(respBody)
|
|
}
|
|
activities = append(activities, entry)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Activity list rows error for %s: %v", workspaceID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, activities)
|
|
}
|
|
|
|
// SessionSearch handles GET /workspaces/:id/session-search?q=&limit=
|
|
// It searches the workspace's own activity logs and memories without adding a new storage layer.
|
|
func (h *ActivityHandler) SessionSearch(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
query, limit := parseSessionSearchParams(c)
|
|
|
|
sqlQuery, args := buildSessionSearchQuery(workspaceID, query, limit)
|
|
|
|
rows, err := db.DB.QueryContext(c.Request.Context(), sqlQuery, args...)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "session search failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
items, scanErr := scanSessionSearchRows(rows)
|
|
if scanErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// parseSessionSearchParams extracts the `q` and `limit` query params for SessionSearch,
|
|
// applying the default limit (50) and cap (200).
|
|
func parseSessionSearchParams(c *gin.Context) (string, int) {
|
|
query := strings.TrimSpace(c.DefaultQuery("q", ""))
|
|
limit := 50
|
|
if n, err := strconv.Atoi(c.DefaultQuery("limit", "50")); err == nil && n > 0 {
|
|
limit = n
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
}
|
|
return query, limit
|
|
}
|
|
|
|
// buildSessionSearchQuery composes the UNION-ALL SQL across activity_logs and
|
|
// agent_memories with an optional ILIKE filter, returning the SQL string and
|
|
// positional args ready for QueryContext.
|
|
func buildSessionSearchQuery(workspaceID, query string, limit int) (string, []interface{}) {
|
|
sqlQuery := `
|
|
WITH session_items AS (
|
|
SELECT
|
|
'activity' AS kind,
|
|
id,
|
|
workspace_id,
|
|
activity_type AS label,
|
|
COALESCE(summary, '') AS content,
|
|
COALESCE(method, '') AS method,
|
|
COALESCE(status, '') AS status,
|
|
request_body,
|
|
response_body,
|
|
created_at
|
|
FROM activity_logs
|
|
WHERE workspace_id = $1
|
|
UNION ALL
|
|
SELECT
|
|
'memory' AS kind,
|
|
id,
|
|
workspace_id,
|
|
scope AS label,
|
|
content,
|
|
'' AS method,
|
|
'' AS status,
|
|
NULL::jsonb AS request_body,
|
|
NULL::jsonb AS response_body,
|
|
created_at
|
|
FROM agent_memories
|
|
WHERE workspace_id = $1
|
|
)
|
|
SELECT kind, id, workspace_id, label, content, method, status, request_body, response_body, created_at
|
|
FROM session_items
|
|
`
|
|
|
|
args := []interface{}{workspaceID}
|
|
if query != "" {
|
|
sqlQuery += `
|
|
WHERE (
|
|
content ILIKE $2 OR
|
|
label ILIKE $2 OR
|
|
method ILIKE $2 OR
|
|
status ILIKE $2 OR
|
|
COALESCE(request_body::text, '') ILIKE $2 OR
|
|
COALESCE(response_body::text, '') ILIKE $2
|
|
)`
|
|
args = append(args, "%"+query+"%")
|
|
}
|
|
|
|
sqlQuery += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
|
|
args = append(args, limit)
|
|
return sqlQuery, args
|
|
}
|
|
|
|
// scanSessionSearchRows materialises rows from the SessionSearch query into the
|
|
// JSON-shaped maps the endpoint returns. Per-row scan errors are logged and
|
|
// skipped (matches prior behavior); a rows.Err() failure is surfaced.
|
|
func scanSessionSearchRows(rows interface {
|
|
Next() bool
|
|
Scan(dest ...interface{}) error
|
|
Err() error
|
|
}) ([]map[string]interface{}, error) {
|
|
items := make([]map[string]interface{}, 0)
|
|
for rows.Next() {
|
|
var (
|
|
kind, id, wsID, label, content, method, status string
|
|
reqBody, respBody []byte
|
|
createdAt time.Time
|
|
)
|
|
if err := rows.Scan(&kind, &id, &wsID, &label, &content, &method, &status, &reqBody, &respBody, &createdAt); err != nil {
|
|
log.Printf("Session search scan error: %v", err)
|
|
continue
|
|
}
|
|
|
|
item := map[string]interface{}{
|
|
"kind": kind,
|
|
"id": id,
|
|
"workspace_id": wsID,
|
|
"label": label,
|
|
"content": content,
|
|
"method": method,
|
|
"status": status,
|
|
"created_at": createdAt,
|
|
}
|
|
if reqBody != nil {
|
|
item["request_body"] = json.RawMessage(reqBody)
|
|
}
|
|
if respBody != nil {
|
|
item["response_body"] = json.RawMessage(respBody)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Session search rows error: %v", err)
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// Notify handles POST /workspaces/:id/notify — agents push messages to the canvas chat.
|
|
// This enables agents to send interim updates ("I'll check on it") and follow-up results
|
|
// without waiting for the user to poll. Messages are broadcast via WebSocket only.
|
|
func (h *ActivityHandler) Notify(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
var body struct {
|
|
Message string `json:"message" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
|
return
|
|
}
|
|
|
|
// Verify workspace exists
|
|
var wsName string
|
|
err := db.DB.QueryRowContext(c.Request.Context(),
|
|
`SELECT name FROM workspaces WHERE id = $1 AND status != 'removed'`, workspaceID,
|
|
).Scan(&wsName)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
|
|
h.broadcaster.BroadcastOnly(workspaceID, "AGENT_MESSAGE", map[string]interface{}{
|
|
"message": body.Message,
|
|
"workspace_id": workspaceID,
|
|
"name": wsName,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
|
}
|
|
|
|
// Report handles POST /workspaces/:id/activity — agents self-report activity logs.
|
|
func (h *ActivityHandler) Report(c *gin.Context) {
|
|
workspaceID := c.Param("id")
|
|
var body struct {
|
|
ActivityType string `json:"activity_type" binding:"required"`
|
|
Method string `json:"method"`
|
|
Summary string `json:"summary"`
|
|
TargetID string `json:"target_id"`
|
|
SourceID string `json:"source_id"`
|
|
Status string `json:"status"`
|
|
ErrorDetail string `json:"error_detail"`
|
|
DurationMs *int `json:"duration_ms"`
|
|
RequestBody interface{} `json:"request_body"`
|
|
ResponseBody interface{} `json:"response_body"`
|
|
Metadata interface{} `json:"metadata"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate activity type. memory_write was added per #125 so the
|
|
// commit_memory tool can surface in the Canvas Agent Comms tab —
|
|
// previously its writes were invisible outside the agent_memories
|
|
// table.
|
|
switch body.ActivityType {
|
|
case "a2a_send", "a2a_receive", "task_update", "agent_log", "skill_promotion", "memory_write", "error":
|
|
// valid
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid activity_type, must be one of: a2a_send, a2a_receive, task_update, agent_log, skill_promotion, memory_write, error"})
|
|
return
|
|
}
|
|
|
|
status := body.Status
|
|
if status == "" {
|
|
status = "ok"
|
|
}
|
|
|
|
// Resolve request/response body — prefer explicit fields, fall back to metadata
|
|
reqBody := body.RequestBody
|
|
if reqBody == nil {
|
|
reqBody = body.Metadata
|
|
}
|
|
sourceID := body.SourceID
|
|
if sourceID == "" {
|
|
sourceID = workspaceID
|
|
}
|
|
|
|
LogActivity(c.Request.Context(), h.broadcaster, ActivityParams{
|
|
WorkspaceID: workspaceID,
|
|
ActivityType: body.ActivityType,
|
|
SourceID: &sourceID,
|
|
TargetID: nilIfEmpty(body.TargetID),
|
|
Method: nilIfEmpty(body.Method),
|
|
Summary: nilIfEmpty(body.Summary),
|
|
RequestBody: reqBody,
|
|
ResponseBody: body.ResponseBody,
|
|
DurationMs: body.DurationMs,
|
|
Status: status,
|
|
ErrorDetail: nilIfEmpty(body.ErrorDetail),
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "logged"})
|
|
}
|
|
|
|
// LogActivity inserts an activity log and optionally broadcasts via WebSocket.
|
|
func LogActivity(ctx context.Context, broadcaster *events.Broadcaster, params ActivityParams) {
|
|
reqJSON, reqErr := json.Marshal(params.RequestBody)
|
|
if reqErr != nil {
|
|
log.Printf("LogActivity: failed to marshal request_body for %s: %v", params.WorkspaceID, reqErr)
|
|
reqJSON = []byte("null")
|
|
}
|
|
respJSON, respErr := json.Marshal(params.ResponseBody)
|
|
if respErr != nil {
|
|
log.Printf("LogActivity: failed to marshal response_body for %s: %v", params.WorkspaceID, respErr)
|
|
respJSON = []byte("null")
|
|
}
|
|
|
|
var reqStr, respStr *string
|
|
if params.RequestBody != nil {
|
|
s := string(reqJSON)
|
|
reqStr = &s
|
|
}
|
|
if params.ResponseBody != nil {
|
|
s := string(respJSON)
|
|
respStr = &s
|
|
}
|
|
|
|
_, err := db.DB.ExecContext(ctx, `
|
|
INSERT INTO activity_logs (workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, duration_ms, status, error_detail)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11)
|
|
`, params.WorkspaceID, params.ActivityType, params.SourceID, params.TargetID,
|
|
params.Method, params.Summary, reqStr, respStr,
|
|
params.DurationMs, params.Status, params.ErrorDetail)
|
|
if err != nil {
|
|
log.Printf("LogActivity insert error: %v", err)
|
|
return
|
|
}
|
|
|
|
// Broadcast ACTIVITY_LOGGED event
|
|
if broadcaster != nil {
|
|
broadcaster.BroadcastOnly(params.WorkspaceID, "ACTIVITY_LOGGED", map[string]interface{}{
|
|
"activity_type": params.ActivityType,
|
|
"method": params.Method,
|
|
"summary": params.Summary,
|
|
"status": params.Status,
|
|
"source_id": params.SourceID,
|
|
"target_id": params.TargetID,
|
|
"duration_ms": params.DurationMs,
|
|
})
|
|
}
|
|
}
|
|
|
|
type ActivityParams struct {
|
|
WorkspaceID string
|
|
ActivityType string // a2a_send, a2a_receive, task_update, agent_log, skill_promotion, error
|
|
SourceID *string
|
|
TargetID *string
|
|
Method *string
|
|
Summary *string
|
|
RequestBody interface{}
|
|
ResponseBody interface{}
|
|
DurationMs *int
|
|
Status string // ok, error, timeout
|
|
ErrorDetail *string
|
|
}
|