Adds workspaces.delivery_mode (push, default | poll) and lets the register handler accept poll-mode workspaces with no URL. This is the foundation for the unified poll/push delivery design in #2339 — Telegram-getUpdates shape for external runtimes that have no public URL. What this PR does: - Migration 045: NOT NULL TEXT column, default 'push', CHECK constraint on the two valid values. - models.Workspace + RegisterPayload + CreateWorkspacePayload gain a DeliveryMode field. RegisterPayload.URL drops the `binding:"required"` tag — the handler now enforces it conditionally on the resolved mode. - Register handler: validates explicit delivery_mode if set; resolves effective mode (payload value, else stored row value, else push) AFTER the C18 token check; validates URL only when effective mode is push; persists delivery_mode in the upsert; returns it in the response; skips URL caching when payload.URL is empty. - CreateWorkspace handler: persists delivery_mode (defaults to push) in the same INSERT, validates it before any side effects. What this PR does NOT do (intentional, follow-up PRs): - PR 2: short-circuit ProxyA2A for poll-mode workspaces (skip SSRF + dispatch, log a2a_receive activity, return 200). - PR 3: since_id cursor on GET /activity for lossless polling. - Plugin v0.2 in molecule-mcp-claude-channel: cursor persistence + a register helper that creates poll-mode workspaces. Backwards compatibility: every existing workspace stays push-mode (schema default) with identical behavior. New tests: TestRegister_PollMode_AcceptsEmptyURL, TestRegister_PushMode_RejectsEmptyURL, TestRegister_InvalidDeliveryMode, TestRegister_PollMode_PreservesExistingValue. All existing register + create tests updated to expect the new delivery_mode column in the INSERT args. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
9.1 KiB
Go
188 lines
9.1 KiB
Go
package models
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"time"
|
|
)
|
|
|
|
// DefaultMaxConcurrentTasks mirrors the workspaces.max_concurrent_tasks
|
|
// schema default. Handlers that resolve a 0/omitted payload value write
|
|
// this constant so the read-side (scheduler capacity check) sees a
|
|
// guaranteed non-zero column on every row.
|
|
const DefaultMaxConcurrentTasks = 1
|
|
|
|
type Workspace struct {
|
|
ID string `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name"`
|
|
Role sql.NullString `json:"role" db:"role"`
|
|
Tier int `json:"tier" db:"tier"`
|
|
AwarenessNamespace sql.NullString `json:"awareness_namespace" db:"awareness_namespace"`
|
|
Status string `json:"status" db:"status"`
|
|
SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"`
|
|
AgentCard json.RawMessage `json:"agent_card" db:"agent_card"`
|
|
URL sql.NullString `json:"url" db:"url"`
|
|
ParentID *string `json:"parent_id" db:"parent_id"`
|
|
ForwardedTo *string `json:"forwarded_to" db:"forwarded_to"`
|
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at" db:"last_heartbeat_at"`
|
|
LastErrorRate float64 `json:"last_error_rate" db:"last_error_rate"`
|
|
LastSampleError sql.NullString `json:"last_sample_error" db:"last_sample_error"`
|
|
ActiveTasks int `json:"active_tasks" db:"active_tasks"`
|
|
MaxConcurrentTasks int `json:"max_concurrent_tasks" db:"max_concurrent_tasks"`
|
|
UptimeSeconds int `json:"uptime_seconds" db:"uptime_seconds"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
// DeliveryMode: "push" (synchronous to URL — default) or "poll" (logged
|
|
// to activity_logs, agent reads via GET /activity?since_id=). See
|
|
// migration 045 + RFC #2339.
|
|
DeliveryMode string `json:"delivery_mode" db:"delivery_mode"`
|
|
// Canvas layout fields (from JOIN)
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Collapsed bool `json:"collapsed"`
|
|
}
|
|
|
|
// Delivery mode constants. Matches the CHECK constraint in migration 045.
|
|
const (
|
|
DeliveryModePush = "push"
|
|
DeliveryModePoll = "poll"
|
|
)
|
|
|
|
// IsValidDeliveryMode reports whether s is one of the recognised
|
|
// delivery modes. Empty string is NOT valid here — callers must
|
|
// resolve the default ("push") before calling.
|
|
func IsValidDeliveryMode(s string) bool {
|
|
return s == DeliveryModePush || s == DeliveryModePoll
|
|
}
|
|
|
|
type RegisterPayload struct {
|
|
ID string `json:"id" binding:"required"`
|
|
// URL is required for push-mode workspaces; optional / unused for
|
|
// poll-mode (the platform never dispatches to it). The handler
|
|
// enforces the conditional requirement based on the resolved
|
|
// delivery mode (payload value, falling back to the row's existing
|
|
// value, falling back to "push").
|
|
URL string `json:"url"`
|
|
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
|
|
// DeliveryMode is optional. Empty string means "keep the existing
|
|
// value on the workspace row, or default to push for new rows".
|
|
// When set, must be one of DeliveryModePush / DeliveryModePoll.
|
|
DeliveryMode string `json:"delivery_mode,omitempty"`
|
|
}
|
|
|
|
type HeartbeatPayload struct {
|
|
WorkspaceID string `json:"workspace_id" binding:"required"`
|
|
ErrorRate float64 `json:"error_rate"`
|
|
SampleError string `json:"sample_error"`
|
|
ActiveTasks int `json:"active_tasks"`
|
|
UptimeSeconds int `json:"uptime_seconds"`
|
|
CurrentTask string `json:"current_task"`
|
|
// MonthlySpend is cumulative USD spend for the current calendar month,
|
|
// denominated in cents (e.g. 1500 = $15.00). Zero means "no update" —
|
|
// the heartbeat handler never writes zero to avoid accidentally clearing
|
|
// a previously-reported spend value. Any non-zero value is clamped to
|
|
// [0, maxMonthlySpend] before the DB write. (#615)
|
|
MonthlySpend int64 `json:"monthly_spend"`
|
|
// RuntimeState is a self-reported runtime health flag separate from
|
|
// "is the heartbeat task firing at all". The heartbeat task lives in
|
|
// its own asyncio task and keeps pinging even when the agent runtime
|
|
// is wedged (e.g. claude_agent_sdk's `Control request timeout:
|
|
// initialize` leaves the SDK in a permanent error state for the
|
|
// process lifetime). RuntimeState is how the workspace tells the
|
|
// platform "I'm alive but my Claude runtime is broken — flip me to
|
|
// degraded so the canvas can show a Restart hint."
|
|
//
|
|
// Empty string = healthy / no signal. The only currently-recognised
|
|
// non-empty value is "wedged"; future values can extend this without
|
|
// migration.
|
|
RuntimeState string `json:"runtime_state"`
|
|
|
|
// RuntimeMetadata is the adapter-declared capability map + per-
|
|
// capability override values. The Python runtime builds this from
|
|
// BaseAdapter.capabilities() + per-hook methods (e.g.
|
|
// idle_timeout_override()) — see workspace/heartbeat.py:
|
|
// _runtime_metadata_payload. Optional: missing means "use platform
|
|
// defaults for everything", matching pre-2026-04 behavior.
|
|
//
|
|
// Pointer (not value) so a missing JSON field is nil rather than a
|
|
// zero-value RuntimeMetadata{} that would falsely claim "all caps =
|
|
// false declared explicitly". Lets the platform distinguish "adapter
|
|
// said no native ownership" from "old runtime version, didn't say".
|
|
RuntimeMetadata *RuntimeMetadata `json:"runtime_metadata,omitempty"`
|
|
}
|
|
|
|
// RuntimeMetadata is the adapter-declared capability + override block
|
|
// the Python runtime sends in the heartbeat payload. New fields can be
|
|
// added with `omitempty` without breaking older runtime versions.
|
|
//
|
|
// See project memory `project_runtime_native_pluggable.md` for the
|
|
// principle and workspace/adapter_base.py:RuntimeCapabilities for the
|
|
// Python source of truth.
|
|
type RuntimeMetadata struct {
|
|
// Capabilities maps capability name → "adapter owns it natively".
|
|
// Keys (heartbeat, scheduler, session, status_mgmt, retry,
|
|
// activity_decoration, channel_dispatch) match
|
|
// RuntimeCapabilities.to_dict() in adapter_base.py — keep in sync.
|
|
Capabilities map[string]bool `json:"capabilities,omitempty"`
|
|
|
|
// IdleTimeoutSeconds, when set, overrides the per-dispatch silence
|
|
// window in a2a_proxy.go for this workspace's A2A traffic. Pointer
|
|
// so nil means "no override; use the global default". Zero / negative
|
|
// is treated as nil by the consumer (a2a_proxy.go).
|
|
IdleTimeoutSeconds *int `json:"idle_timeout_seconds,omitempty"`
|
|
}
|
|
|
|
type UpdateCardPayload struct {
|
|
WorkspaceID string `json:"workspace_id" binding:"required"`
|
|
AgentCard json.RawMessage `json:"agent_card" binding:"required"`
|
|
}
|
|
|
|
// MemorySeed represents an initial memory to seed into a workspace at creation time.
|
|
// Used by both the POST /workspaces API and org template import to pre-populate
|
|
// agent memories from config (issue #1050).
|
|
type MemorySeed struct {
|
|
Content string `json:"content" yaml:"content"`
|
|
Scope string `json:"scope" yaml:"scope"` // LOCAL, TEAM, GLOBAL
|
|
}
|
|
|
|
type CreateWorkspacePayload struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Role string `json:"role"`
|
|
Template string `json:"template"` // workspace-configs-templates folder name
|
|
Tier int `json:"tier"`
|
|
Model string `json:"model"`
|
|
Runtime string `json:"runtime"` // "langgraph" (default), "claude-code", etc.
|
|
External bool `json:"external"` // true = no Docker container, just a registered URL
|
|
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
|
|
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
|
|
// "poll" records inbound to activity_logs for the agent to consume via
|
|
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.
|
|
DeliveryMode string `json:"delivery_mode,omitempty"`
|
|
WorkspaceDir string `json:"workspace_dir"` // host path to mount as /workspace (empty = isolated volume)
|
|
WorkspaceAccess string `json:"workspace_access"` // "none" (default), "read_only", or "read_write" — see #65
|
|
ParentID *string `json:"parent_id"`
|
|
// BudgetLimit is the optional monthly spend ceiling in USD cents.
|
|
// NULL (omitted) means no limit. budget_limit=500 means $5.00/month.
|
|
BudgetLimit *int64 `json:"budget_limit"`
|
|
// Secrets is an optional map of key→plaintext-value pairs to persist as
|
|
// workspace secrets at creation time. Stored encrypted (same path as
|
|
// POST /workspaces/:id/secrets). Nil/empty map is a no-op.
|
|
Secrets map[string]string `json:"secrets"`
|
|
// MaxConcurrentTasks caps parallel A2A + cron dispatch. 0 means use
|
|
// DefaultMaxConcurrentTasks. Leaders typically set 3.
|
|
MaxConcurrentTasks int `json:"max_concurrent_tasks"`
|
|
Canvas struct {
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
} `json:"canvas"`
|
|
// InitialMemories is an optional list of memories to seed into the
|
|
// workspace immediately after creation. Each entry is inserted into
|
|
// agent_memories with the workspace's awareness namespace. Issue #1050.
|
|
InitialMemories []MemorySeed `json:"initial_memories"`
|
|
}
|
|
|
|
type CheckAccessPayload struct {
|
|
CallerID string `json:"caller_id" binding:"required"`
|
|
TargetID string `json:"target_id" binding:"required"`
|
|
}
|