Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86df02c38f | |||
| 99d4a44250 | |||
| 8ae3cb6917 | |||
| 7b3fc0f2ef |
@@ -337,7 +337,7 @@ func TestRegister_ProvisionerURLPreserved(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push").
|
||||
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// DB returns provisioner URL (127.0.0.1) — should take precedence over agent-reported URL
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestRegisterHandler(t *testing.T) {
|
||||
|
||||
// Expect the upsert INSERT ... ON CONFLICT
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push").
|
||||
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect the SELECT url query (for cache URL logic)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
// kind_platform_root_integration_test.go — REAL Postgres gate for the
|
||||
// platform-agent participant kind (RFC docs/design/rfc-platform-agent.md).
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
|
||||
// go test -tags=integration ./internal/handlers/ -run Integration_PlatformKind -v
|
||||
//
|
||||
// CI: piggybacks on the handlers-postgres-integration workflow (path filter
|
||||
// includes workspace-server/internal/handlers/** and migrations/**).
|
||||
//
|
||||
// Why this is NOT a sqlmock test
|
||||
// ------------------------------
|
||||
// The invariant "a platform agent must be the org root (parent_id IS NULL),
|
||||
// which structurally also means at most one platform agent per org" is enforced
|
||||
// by the workspaces_platform_root_check CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK
|
||||
// constraint, so only a real Postgres can prove the constraint actually rejects
|
||||
// a non-root platform agent and accepts a root one. The Register handler's
|
||||
// isPlatformRootViolation()/409 path depends on this constraint firing.
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func integrationDB_PlatformKind(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
url := requireIntegrationDBURL(t)
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestIntegration_PlatformKind_RootAllowed_NonRootRejected proves the three
|
||||
// guarantees of the kind column against a real Postgres:
|
||||
//
|
||||
// 1. a fresh workspace defaults to kind='workspace';
|
||||
// 2. a root row (parent_id IS NULL) may be kind='platform';
|
||||
// 3. a non-root row (parent_id set) may NOT be kind='platform' — the
|
||||
// workspaces_platform_root_check constraint rejects it (23514).
|
||||
func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) {
|
||||
conn := integrationDB_PlatformKind(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := fmt.Sprintf("itest-kind-%s", uuid.New().String()[:8])
|
||||
cleanup := func() {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
|
||||
t.Logf("cleanup (non-fatal): %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup() // pre-test hygiene in the shared integration DB
|
||||
|
||||
rootID := uuid.New().String()
|
||||
childID := uuid.New().String()
|
||||
|
||||
// 1. Default kind is 'workspace' when the column is omitted on INSERT.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', NULL)
|
||||
`, rootID, prefix+"-root"); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
var gotKind string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT kind FROM workspaces WHERE id = $1`, rootID).Scan(&gotKind); err != nil {
|
||||
t.Fatalf("read kind: %v", err)
|
||||
}
|
||||
if gotKind != "workspace" {
|
||||
t.Fatalf("default kind = %q, want \"workspace\"", gotKind)
|
||||
}
|
||||
|
||||
// 2. The root row may become a platform agent.
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, rootID); err != nil {
|
||||
t.Fatalf("promote root to platform: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// A child of the platform root (an ordinary workspace) inserts fine.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', $3)
|
||||
`, childID, prefix+"-child", rootID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
|
||||
// 3. The non-root child may NOT be a platform agent — the CHECK rejects it.
|
||||
_, err := conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, childID)
|
||||
if err == nil {
|
||||
t.Fatalf("non-root child accepted kind='platform' — constraint did not fire")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "workspaces_platform_root_check") {
|
||||
t.Fatalf("non-root platform rejection wanted workspaces_platform_root_check, got: %v", err)
|
||||
}
|
||||
|
||||
// And the unknown-kind value is rejected by workspaces_kind_check.
|
||||
_, err = conn.ExecContext(ctx,
|
||||
`UPDATE workspaces SET kind = 'bogus' WHERE id = $1`, rootID)
|
||||
if err == nil || !strings.Contains(err.Error(), "workspaces_kind_check") {
|
||||
t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
|
||||
// 1. Strip plugin's rule/fragment markers from CLAUDE.md (mirrors
|
||||
// AgentskillsAdaptor.uninstall lines 184-188). Best-effort: if
|
||||
// the user edited CLAUDE.md, our marker stays untouched.
|
||||
h.stripPluginMarkersFromMemory(ctx, workspaceID, containerName, pluginName)
|
||||
h.stripPluginMarkersFromMemory(ctx, containerName, pluginName)
|
||||
|
||||
// 2. Remove copied skill dirs declared in the plugin's plugin.yaml.
|
||||
for _, skill := range skillNames {
|
||||
@@ -171,11 +171,9 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
|
||||
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
|
||||
continue
|
||||
}
|
||||
if _, rmErr := h.execAsRoot(ctx, containerName, []string{
|
||||
_, _ = h.execAsRoot(ctx, containerName, []string{
|
||||
"rm", "-rf", "/configs/skills/" + skill,
|
||||
}); rmErr != nil {
|
||||
log.Printf("Plugin uninstall: failed to remove skill %s from %s: %v", skill, workspaceID, rmErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Delete the plugin directory itself (as root to handle file ownership).
|
||||
|
||||
@@ -393,7 +393,7 @@ func (h *PluginsHandler) readPluginSkillsFromContainer(ctx context.Context, cont
|
||||
// `# Plugin: <name> /` — mirrors AgentskillsAdaptor.uninstall's stripping
|
||||
// logic so install/uninstall are symmetric. Best-effort: silent on read or
|
||||
// write failure, since the rest of uninstall must still succeed.
|
||||
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, workspaceID, containerName, pluginName string) {
|
||||
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, containerName, pluginName string) {
|
||||
// Use sed via bash -c for atomic in-place delete: drop the marker line
|
||||
// and the blank line that follows it (install adds a leading blank line
|
||||
// before the marker via append_to_memory). Three sed passes mirror the
|
||||
@@ -417,9 +417,7 @@ func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, works
|
||||
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' /configs/CLAUDE.md > /tmp/claude.new && mv /tmp/claude.new /configs/CLAUDE.md`,
|
||||
regexpEscapeForAwk(marker),
|
||||
)
|
||||
if _, awkErr := h.execAsRoot(ctx, containerName, []string{"bash", "-c", script}); awkErr != nil {
|
||||
log.Printf("Plugin uninstall: failed to strip markers from CLAUDE.md for %s in %s: %v", pluginName, workspaceID, awkErr)
|
||||
}
|
||||
_, _ = h.execAsRoot(ctx, containerName, []string{"bash", "-c", script})
|
||||
}
|
||||
|
||||
// regexpEscapeForAwk escapes characters that have special meaning inside an
|
||||
|
||||
@@ -164,6 +164,20 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
|
||||
return models.DeliveryModePush, nil
|
||||
}
|
||||
|
||||
// errPlatformNotRoot is the client-facing message when a register call tried to
|
||||
// mark a non-root workspace as a platform agent.
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)"
|
||||
|
||||
// isPlatformRootViolation reports whether err is the DB rejecting a register
|
||||
// that tried to mark a non-root workspace as a platform agent (the
|
||||
// workspaces_platform_root_check CHECK constraint). The handler maps it to a
|
||||
// friendly HTTP 409 instead of a raw 500. The invariant — platform == org root,
|
||||
// which structurally also guarantees one platform agent per org — is enforced
|
||||
// race-proof at the DB level; this is just the friendly surface.
|
||||
func isPlatformRootViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check")
|
||||
}
|
||||
|
||||
// Returns a non-nil error suitable for including in a 400 Bad Request response.
|
||||
func validateAgentURL(rawURL string) error {
|
||||
if rawURL == "" {
|
||||
@@ -277,6 +291,14 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate explicit kind if the agent declared one; empty is allowed and
|
||||
// resolves to the row's existing value (or "workspace" default) in
|
||||
// resolveKind below. Only the platform-agent container declares 'platform'.
|
||||
if payload.Kind != "" && !models.IsValidKind(payload.Kind) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be 'workspace' or 'platform'"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// C18: prevent workspace URL hijacking on re-registration.
|
||||
@@ -390,9 +412,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
// the row. Without this guard, bulk deletes left tier-3 stragglers because
|
||||
// the last pre-teardown heartbeat flipped status back to 'online' after
|
||||
// Delete's UPDATE.
|
||||
// kind ($6) is the raw payload value (validated above; "" = unspecified).
|
||||
// COALESCE(NULLIF($6,''), …) means: an explicit kind wins; an unspecified
|
||||
// kind defaults to 'workspace' for a NEW row and KEEPS the existing kind on
|
||||
// re-register (so a platform agent re-registering without kind is never
|
||||
// downgraded). A non-root row asking for 'platform' is rejected by the
|
||||
// workspaces_platform_root_check constraint → friendly 409 below.
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5)
|
||||
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode, kind)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5, COALESCE(NULLIF($6, ''), 'workspace'))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
url = CASE
|
||||
WHEN workspaces.url LIKE 'http://127.0.0.1%' THEN workspaces.url
|
||||
@@ -402,10 +430,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
status = 'online',
|
||||
last_heartbeat_at = now(),
|
||||
delivery_mode = EXCLUDED.delivery_mode,
|
||||
kind = COALESCE(NULLIF($6, ''), workspaces.kind),
|
||||
updated_at = now()
|
||||
WHERE workspaces.status IS DISTINCT FROM 'removed'
|
||||
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert)
|
||||
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert, payload.Kind)
|
||||
if err != nil {
|
||||
if isPlatformRootViolation(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": errPlatformNotRoot})
|
||||
return
|
||||
}
|
||||
log.Printf("Registry register error: %v (id=%s)", err, payload.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestRegister_DBError(t *testing.T) {
|
||||
|
||||
// DB insert fails
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push").
|
||||
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push", "").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -647,7 +647,7 @@ func TestRegister_GuardAgainstResurrectingRemovedRow(t *testing.T) {
|
||||
// This regex-ish match requires the guard. If the handler ever drops
|
||||
// the clause the test fails because the emitted SQL won't match.
|
||||
mock.ExpectExec("ON CONFLICT.*WHERE workspaces.status IS DISTINCT FROM 'removed'").
|
||||
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push").
|
||||
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected = correctly guarded
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs("ws-resurrect").
|
||||
@@ -917,7 +917,7 @@ func TestRegister_C18_BootstrapAllowedNoTokens(t *testing.T) {
|
||||
|
||||
// Workspace upsert proceeds normally.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push").
|
||||
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
@@ -1228,7 +1228,7 @@ func TestRegister_DBErrorResponseIsOpaque(t *testing.T) {
|
||||
|
||||
// DB upsert fails with a descriptive internal error.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push").
|
||||
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push", "").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1476,7 +1476,7 @@ func TestRegister_PollMode_AcceptsEmptyURL(t *testing.T) {
|
||||
|
||||
// Upsert MUST run with empty URL (sql.NullString) and delivery_mode=poll.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// SELECT url for cache: returns NULL/empty for poll-mode rows. The
|
||||
@@ -1591,6 +1591,89 @@ func TestRegister_InvalidDeliveryMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_InvalidKind rejects payloads that declare an unrecognised kind —
|
||||
// only 'workspace' and 'platform' are valid. Mirrors the delivery_mode guard;
|
||||
// the rejection happens before any DB access.
|
||||
func TestRegister_InvalidKind(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"ws-x","url":"http://localhost:8000","agent_card":{"name":"a"},"kind":"bogus"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid kind: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "kind") {
|
||||
t.Errorf("expected error body to mention kind, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering
|
||||
// with kind="platform" has that value written through the upsert (the platform
|
||||
// agent self-registers as the org root). The platform==root invariant itself is
|
||||
// enforced by the workspaces_platform_root_check DB constraint and exercised by
|
||||
// the integration test, which sqlmock cannot enforce.
|
||||
func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
const wsID = "ws-platform-agent"
|
||||
|
||||
// Bootstrap path — no live tokens.
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// delivery_mode="push" is set explicitly, so resolveDeliveryMode
|
||||
// short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry
|
||||
// kind="platform" as the 6th arg.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, "http://localhost:9100", `{"name":"concierge"}`, "push", "platform").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://localhost:9100"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Token issuance — first-register path.
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"concierge"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PollMode_PreservesExistingValue: when the row already
|
||||
// has delivery_mode=poll and the payload doesn't set it, the resolved
|
||||
// mode should be poll — i.e. "absent payload mode" must NOT silently
|
||||
@@ -1616,7 +1699,7 @@ func TestRegister_PollMode_PreservesExistingValue(t *testing.T) {
|
||||
// Upsert carries the resolved poll mode forward — even though
|
||||
// payload didn't restate it. URL still empty (poll-mode shape).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1685,7 +1768,7 @@ func TestRegister_ExternalRuntime_DefaultsToPoll(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "external"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1744,7 +1827,7 @@ func TestRegister_KimiRuntime_DefaultsToPoll(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "kimi-cli"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
@@ -1804,7 +1887,7 @@ func TestRegister_NonExternalRuntime_StillDefaultsToPush(t *testing.T) {
|
||||
AddRow(sql.NullString{}, "claude-code"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push").
|
||||
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push", "").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
|
||||
@@ -13,11 +13,16 @@ import (
|
||||
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"`
|
||||
Status string `json:"status" db:"status"`
|
||||
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"`
|
||||
Status string `json:"status" db:"status"`
|
||||
// Kind: "workspace" (default) or "platform". A "platform" workspace is the
|
||||
// org-level concierge (the platform agent) that sits at the org root and is
|
||||
// the user's default A2A target. See migration
|
||||
// 20260606000000_workspaces_kind + RFC docs/design/rfc-platform-agent.md.
|
||||
Kind string `json:"kind" db:"kind"`
|
||||
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"`
|
||||
@@ -63,6 +68,21 @@ func IsValidDeliveryMode(s string) bool {
|
||||
return s == DeliveryModePush || s == DeliveryModePoll
|
||||
}
|
||||
|
||||
// Workspace kind constants. Matches the CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. KindPlatform marks the org-level concierge
|
||||
// (the platform agent) which sits at the org root; see
|
||||
// docs/design/rfc-platform-agent.md.
|
||||
const (
|
||||
KindWorkspace = "workspace"
|
||||
KindPlatform = "platform"
|
||||
)
|
||||
|
||||
// IsValidKind reports whether s is a recognised workspace kind. Empty string is
|
||||
// NOT valid here — callers resolve the default (KindWorkspace) before calling.
|
||||
func IsValidKind(s string) bool {
|
||||
return s == KindWorkspace || s == KindPlatform
|
||||
}
|
||||
|
||||
type RegisterPayload struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
// URL is required for push-mode workspaces; optional / unused for
|
||||
@@ -76,6 +96,12 @@ type RegisterPayload struct {
|
||||
// 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"`
|
||||
// Kind is optional. Empty string means "keep the existing value on the
|
||||
// workspace row, or default to KindWorkspace for new rows". When set, must
|
||||
// be one of KindWorkspace / KindPlatform. KindPlatform additionally requires
|
||||
// the row to be its own org root (parent_id IS NULL) and to be the only
|
||||
// platform agent in the org — enforced by the Register handler.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type HeartbeatPayload struct {
|
||||
|
||||
@@ -34,6 +34,35 @@ func TestIsValidDeliveryMode_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== IsValidKind ====================
|
||||
|
||||
func TestIsValidKind_Valid(t *testing.T) {
|
||||
for _, k := range []string{KindWorkspace, KindPlatform} {
|
||||
if !IsValidKind(k) {
|
||||
t.Errorf("IsValidKind(%q) = false, want true", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidKind_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false}, // empty is not valid — callers resolve the default
|
||||
{"platforms", false}, // typo
|
||||
{"Platform", false}, // case-sensitive
|
||||
{"platform ", false}, // trailing space
|
||||
{"root", false}, // not a kind
|
||||
{"user", false}, // the user is implicit, not a workspace kind
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsValidKind(tc.val); got != tc.want {
|
||||
t.Errorf("IsValidKind(%q) = %v, want %v", tc.val, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WorkspaceStatus ====================
|
||||
|
||||
func TestWorkspaceStatus_String(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reverse the participant-kind discriminator.
|
||||
-- Non-destructive: dropping the column makes every workspace an ordinary
|
||||
-- workspace again (the platform agent loses its marker but its row survives).
|
||||
DROP INDEX IF EXISTS idx_workspaces_kind;
|
||||
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_platform_root_check;
|
||||
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_kind_check;
|
||||
ALTER TABLE workspaces DROP COLUMN IF EXISTS kind;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Participant-kind discriminator for the org-level platform agent.
|
||||
-- (RFC: docs/design/rfc-platform-agent.md)
|
||||
--
|
||||
-- 'workspace' (default) = an ordinary workspace / agent.
|
||||
-- 'platform' = the org-level concierge (the "platform agent"). It is
|
||||
-- the single org root (parent_id IS NULL) and the user's
|
||||
-- default A2A chat target. Exactly one per org.
|
||||
--
|
||||
-- There is no org_id column — an "org" is the parent_id-chain root resolved by
|
||||
-- org_scope.go (orgRootID/sameOrg). "platform == org root" and "one platform
|
||||
-- agent per org" are therefore enforced in the Register/create handlers, not in
|
||||
-- pure SQL. This column is only the discriminator (default-target / billing
|
||||
-- exclusion / UX), defined once here and mirrored by the Go constants
|
||||
-- models.KindWorkspace / models.KindPlatform.
|
||||
--
|
||||
-- Backward-compatible: every existing row defaults to 'workspace'. The CHECK is
|
||||
-- added NOT VALID then validated so the ALTER can never fail on legacy data.
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'workspace';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_kind_check') THEN
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT workspaces_kind_check CHECK (kind IN ('workspace', 'platform')) NOT VALID;
|
||||
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_kind_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- platform == org root, enforced at the DB level (race-proof). A platform agent
|
||||
-- MUST have parent_id IS NULL. Because an org is the subtree under a single
|
||||
-- parent_id IS NULL root (org_scope.go) and only a root may be 'platform', this
|
||||
-- also structurally guarantees at most ONE platform agent per org. The handler
|
||||
-- additionally pre-checks this to return a friendly 409 instead of a raw 23514.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_platform_root_check') THEN
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT workspaces_platform_root_check
|
||||
CHECK (kind <> 'platform' OR parent_id IS NULL) NOT VALID;
|
||||
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_platform_root_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workspaces_kind ON workspaces(kind);
|
||||
Reference in New Issue
Block a user