mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
ff7e7d51cb
BUG-2014 (PR #867) added Degraded + DegradedSections to DashboardResponse so callers can tell a failed sub-query apart from a genuinely-empty section. BUG-2072 reported that the slim BootstrapDashboard projection omits them — but BootstrapDashboard embeds *DashboardResponse anonymously, so encoding/json already promotes both fields into the bootstrap wire shape. Verified empirically: the MCP pad_meta.action=bootstrap tool, the pad://workspace/{ws}/bootstrap resource, and the pad_set_workspace embed all serialize this same struct, so partial-failure state already reaches every agent surface. The promotion was untested and undocumented, so a future refactor to an explicit slim projection (like BootstrapCollection / BootstrapRole) could silently drop it. This pins the behavior: - TestBootstrapDashboardCarriesDegraded asserts on the marshaled JSON (not just promoted field access) that degraded=true + the failed section names flow through, and that a healthy dashboard omits degraded_sections. - BootstrapDashboard godoc now documents the promotion + the carry-across requirement for any future explicit projection. No payload change — the fields were already present. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
1124 lines
45 KiB
Go
1124 lines
45 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// TestBootstrapEmptyWorkspace verifies the bootstrap blob returns the
|
|
// scaffolding for a workspace with no items beyond the template seeds.
|
|
// The /pad skill relies on this single call replacing four separate
|
|
// context-loading calls; any of the expected keys missing would silently
|
|
// break greeting behavior.
|
|
func TestBootstrapEmptyWorkspace(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
|
|
if b.Workspace.Slug != slug {
|
|
t.Errorf("workspace.slug = %q, want %q", b.Workspace.Slug, slug)
|
|
}
|
|
if b.Workspace.Name == "" {
|
|
t.Error("workspace.name empty")
|
|
}
|
|
|
|
if len(b.Collections) == 0 {
|
|
t.Error("collections empty — template seed must produce at least Tasks/Ideas/Plans/Docs/Conventions/Playbooks")
|
|
}
|
|
|
|
// Roles is non-nil by contract — agents iterate it without nil-checks.
|
|
if b.Roles == nil {
|
|
t.Error("roles is nil; should be an empty slice")
|
|
}
|
|
|
|
// Dashboard is populated when a request context is available;
|
|
// recent_activity lives inside it (the top-level duplicate was
|
|
// retired in PLAN-1410 / TASK-1413). On the empty-workspace path
|
|
// dashboard may itself be nil if buildDashboardResponse returns
|
|
// an empty/error result — the agent tolerates that via the
|
|
// omitempty tag, so we don't assert non-nil here.
|
|
if b.Dashboard != nil && b.Dashboard.RecentActivity == nil {
|
|
t.Error("dashboard.recent_activity is nil; should be an empty slice")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapNeedsOnboardingFlag locks PLAN-1496 / TASK-1504's
|
|
// contract: the `needs_onboarding` flag is true on a freshly-created
|
|
// workspace (no user items yet — only template seeds) and flips to
|
|
// false the moment any user/agent-created item exists.
|
|
//
|
|
// The flag drives the agent skill's bootstrap nudge ("this workspace
|
|
// hasn't been set up yet — say /pad onboard"). If this test breaks,
|
|
// the nudge fires forever or never.
|
|
func TestBootstrapNeedsOnboardingFlag(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// Step 1: fresh workspace. createWSForTest hits POST /workspaces
|
|
// with no Template field, so SeedCollectionsFromTemplate runs
|
|
// with templateName="" — backward-compat path that ships ONLY
|
|
// the default system collections and zero seeded items
|
|
// (TASK-1500's onboard auto-seed is gated on a non-empty
|
|
// templateName). Same shape pad-cloud uses for its in-process
|
|
// seed. needs_onboarding should be true here.
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap (initial): %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
if !b.NeedsOnboarding {
|
|
t.Errorf("fresh workspace: needs_onboarding = false, want true (no user items yet)")
|
|
}
|
|
|
|
// Step 2: create a user-side item. Any source != 'template'
|
|
// flips the flag. Item creation via POST /items defaults to
|
|
// source='cli' (matches CLI + Remote MCP attribution).
|
|
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]any{
|
|
"title": "First real task",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create user item: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Step 3: re-fetch bootstrap; flag must be false now.
|
|
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap (after user item): %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
b = AgentBootstrap{}
|
|
parseJSON(t, rr, &b)
|
|
if b.NeedsOnboarding {
|
|
t.Errorf("after creating user item: needs_onboarding = true, want false (nudge should disappear)")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapBlankWorkspaceOnboardReady is the end-to-end happy-path
|
|
// integration test for PLAN-1496. It ties together the three pieces a
|
|
// fresh `blank` workspace needs for the agent-driven onboarding flow
|
|
// to work on day one:
|
|
//
|
|
// 1. needs_onboarding == true so the skill renders the bootstrap nudge.
|
|
// 2. The /pad onboard playbook is seeded into the workspace.
|
|
// 3. The playbook is status=active (so slug routing dispatches to it).
|
|
//
|
|
// Each piece has its own focused test elsewhere (NeedsOnboardingFlag,
|
|
// SeedFromTemplateAlwaysIncludesOnboardPlaybook,
|
|
// OnboardPlaybook_Contract). This test is the integration smoke: if
|
|
// any one of them silently regresses, /pad onboard breaks in a fresh
|
|
// blank workspace and the design's premise collapses. PLAN-1496 /
|
|
// TASK-1507.
|
|
func TestBootstrapBlankWorkspaceOnboardReady(t *testing.T) {
|
|
srv := testServer(t)
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces", map[string]string{
|
|
"name": "Blank Onboard Ready",
|
|
"template": "blank",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create blank workspace: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var ws models.Workspace
|
|
parseJSON(t, rr, &ws)
|
|
|
|
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
|
|
if !b.NeedsOnboarding {
|
|
t.Errorf("blank workspace: needs_onboarding = false, want true — the bootstrap nudge will never fire")
|
|
}
|
|
|
|
// Find the onboard playbook by its invocation_slug. The skill
|
|
// dispatches /pad onboard by slug-routing through this exact
|
|
// field; if it's missing or not active, slug routing falls
|
|
// through to NL and the playbook is effectively invisible.
|
|
var onboard *AgentBootstrapPlaybookMeta
|
|
for i := range b.Playbooks {
|
|
if b.Playbooks[i].InvocationSlug == "onboard" {
|
|
onboard = &b.Playbooks[i]
|
|
break
|
|
}
|
|
}
|
|
if onboard == nil {
|
|
titles := make([]string, 0, len(b.Playbooks))
|
|
for _, p := range b.Playbooks {
|
|
titles = append(titles, p.Title)
|
|
}
|
|
t.Fatalf("blank workspace bootstrap missing onboard playbook; got titles: %v", titles)
|
|
}
|
|
if onboard.Status != "active" {
|
|
t.Errorf("onboard playbook status = %q, want %q (slug routing only dispatches active playbooks)", onboard.Status, "active")
|
|
}
|
|
if onboard.Trigger != "manual" {
|
|
t.Errorf("onboard playbook trigger = %q, want %q (must be in the blank template's seeded vocabulary)", onboard.Trigger, "manual")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapNeedsOnboardingIgnoresTemplateSeeds is a focused guard:
|
|
// even when a templated workspace ships seeded items
|
|
// (conventions/playbooks/the onboard playbook itself), they MUST NOT
|
|
// flip needs_onboarding to false — only USER-created items count.
|
|
// Otherwise startup/scrum/product workspaces would never show the
|
|
// nudge and the /pad onboard discovery loop falls apart.
|
|
func TestBootstrapNeedsOnboardingIgnoresTemplateSeeds(t *testing.T) {
|
|
srv := testServer(t)
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces", map[string]string{
|
|
"name": "Startup Needs Onboarding",
|
|
"template": "startup",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create workspace: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var ws models.Workspace
|
|
parseJSON(t, rr, &ws)
|
|
|
|
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
if !b.NeedsOnboarding {
|
|
t.Errorf("startup template workspace with only seeded conventions/playbooks: needs_onboarding = false, want true (template seeds don't count)")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapEmptyArraysNotNull verifies the JSON wire shape: arrays
|
|
// must serialize as [] not null so the agent skill can iterate without
|
|
// defensive nil checks.
|
|
func TestBootstrapEmptyArraysNotNull(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode raw: %v", err)
|
|
}
|
|
|
|
// PLAN-1410 / TASK-1413: the top-level `recent_activity` key was
|
|
// retired (duplicate of dashboard.recent_activity). The remaining
|
|
// top-level array fields must still serialize as [] not null.
|
|
for _, key := range []string{"collections", "conventions", "convention_index", "roles", "playbooks"} {
|
|
val, ok := raw[key]
|
|
if !ok {
|
|
t.Errorf("missing key %q in bootstrap response", key)
|
|
continue
|
|
}
|
|
s := string(val)
|
|
if s == "null" {
|
|
t.Errorf("bootstrap.%s serialized as null; want []", key)
|
|
}
|
|
}
|
|
|
|
// Guard against accidental reintroduction of the duplicate
|
|
// top-level recent_activity field.
|
|
if _, ok := raw["recent_activity"]; ok {
|
|
t.Errorf("top-level recent_activity reappeared in bootstrap response; it should live only under dashboard")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapRoleProjection verifies the BootstrapRole wire shape
|
|
// after PLAN-1410 / TASK-1423 trimmed it. Seeds one role and asserts:
|
|
//
|
|
// - The projection's expected fields (slug/name/description/icon/
|
|
// item_count/sort_order) are present and correct.
|
|
// - The dropped fields (id, workspace_id, tools, created_at,
|
|
// updated_at) are NOT present in the marshalled JSON.
|
|
//
|
|
// The negative check is the load-bearing part — without it, a future
|
|
// refactor that "fixes" the projection by re-adding a UUID would
|
|
// pass all the positive assertions silently. Mirrors the contract
|
|
// that TestBootstrapEmptyArraysNotNull guards on the dedup side.
|
|
func TestBootstrapRoleProjection(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// Create a role via the agent-roles endpoint so the workspace
|
|
// has one populated entry to project.
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/agent-roles", map[string]interface{}{
|
|
"name": "Planner",
|
|
"description": "Breaks down ideas, designs approaches",
|
|
"icon": "🧠",
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create role: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Fetch the bootstrap and unmarshal into a permissive shape so we
|
|
// can also detect the presence of fields that should NOT be there.
|
|
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var raw struct {
|
|
Roles []map[string]json.RawMessage `json:"roles"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode roles: %v", err)
|
|
}
|
|
if len(raw.Roles) != 1 {
|
|
t.Fatalf("expected exactly 1 role, got %d", len(raw.Roles))
|
|
}
|
|
role := raw.Roles[0]
|
|
|
|
// Positive: required projection fields are present.
|
|
want := map[string]string{
|
|
"slug": `"planner"`,
|
|
"name": `"Planner"`,
|
|
"description": `"Breaks down ideas, designs approaches"`,
|
|
"item_count": `0`,
|
|
}
|
|
for key, expected := range want {
|
|
got, ok := role[key]
|
|
if !ok {
|
|
t.Errorf("missing required projection field %q", key)
|
|
continue
|
|
}
|
|
if string(got) != expected {
|
|
t.Errorf("role.%s = %s, want %s", key, string(got), expected)
|
|
}
|
|
}
|
|
|
|
// Negative: dropped fields must NOT appear.
|
|
for _, key := range []string{"id", "workspace_id", "tools", "created_at", "updated_at"} {
|
|
if _, ok := role[key]; ok {
|
|
t.Errorf("role.%s leaked into the bootstrap projection; should have been dropped by TASK-1423", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBootstrapFieldDefMirrorsModelsFieldDef is the drift detector for
|
|
// the bootstrapFieldDef parallel struct introduced in TASK-1424.
|
|
// bootstrapFieldDef must stay structurally aligned with
|
|
// models.FieldDef — same field names, same JSON tags (modulo the
|
|
// deliberate `omitempty` on Label and the `json.RawMessage` swap for
|
|
// Default). If models.FieldDef adds a new field, this test fails and
|
|
// the developer is forced to either mirror it here or explicitly
|
|
// decide to drop it from the bootstrap projection.
|
|
//
|
|
// We compare structural metadata (name + JSON key + base type) rather
|
|
// than full Go types so the Default field's `any` → `json.RawMessage`
|
|
// swap doesn't trip the check. Detected differences are listed with
|
|
// the field name so the failure message points at the gap directly.
|
|
func TestBootstrapFieldDefMirrorsModelsFieldDef(t *testing.T) {
|
|
canonical := reflect.TypeOf(models.FieldDef{})
|
|
mirror := reflect.TypeOf(bootstrapFieldDef{})
|
|
|
|
if canonical.NumField() != mirror.NumField() {
|
|
t.Fatalf("field count drift: models.FieldDef has %d fields, bootstrapFieldDef has %d — keep them in sync (see godoc on bootstrapFieldDef)",
|
|
canonical.NumField(), mirror.NumField())
|
|
}
|
|
|
|
// Map name → JSON tag for the canonical struct.
|
|
canon := make(map[string]string, canonical.NumField())
|
|
for i := 0; i < canonical.NumField(); i++ {
|
|
f := canonical.Field(i)
|
|
canon[f.Name] = f.Tag.Get("json")
|
|
}
|
|
|
|
// Allowed JSON-tag delta: the canonical has `json:"label"` (no
|
|
// omitempty); the mirror has `json:"label,omitempty"`. Same key,
|
|
// different presence rule, deliberate.
|
|
tagDeltaAllowed := map[string][2]string{
|
|
"Label": {"label", "label,omitempty"},
|
|
}
|
|
|
|
for i := 0; i < mirror.NumField(); i++ {
|
|
mf := mirror.Field(i)
|
|
cTag, ok := canon[mf.Name]
|
|
if !ok {
|
|
t.Errorf("bootstrapFieldDef.%s has no counterpart in models.FieldDef", mf.Name)
|
|
continue
|
|
}
|
|
mTag := mf.Tag.Get("json")
|
|
if mTag == cTag {
|
|
continue
|
|
}
|
|
if allowed, isExpected := tagDeltaAllowed[mf.Name]; isExpected {
|
|
if cTag == allowed[0] && mTag == allowed[1] {
|
|
continue
|
|
}
|
|
}
|
|
t.Errorf("JSON tag drift on %s: models.FieldDef=%q, bootstrapFieldDef=%q (unexpected — update the tagDeltaAllowed table if this divergence is deliberate)",
|
|
mf.Name, cTag, mTag)
|
|
}
|
|
}
|
|
|
|
// TestTrimRedundantSchemaLabels covers the three behaviors of the
|
|
// label-trim projection:
|
|
//
|
|
// 1. Field labels matching TitleCase(key) are dropped.
|
|
// 2. Custom labels (those that differ) are preserved.
|
|
// 3. Missing/empty labels pass through (no-op).
|
|
//
|
|
// Plus a malformed-schema edge case where the function must return
|
|
// the original bytes verbatim rather than blocking the bootstrap
|
|
// response.
|
|
func TestTrimRedundantSchemaLabels(t *testing.T) {
|
|
t.Run("drops-redundant-labels", func(t *testing.T) {
|
|
in := []byte(`{"fields":[{"key":"status","label":"Status","type":"select"}]}`)
|
|
out := trimRedundantSchemaLabels(in)
|
|
|
|
var parsed map[string][]map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("decode trimmed: %v", err)
|
|
}
|
|
if _, ok := parsed["fields"][0]["label"]; ok {
|
|
t.Errorf("redundant `label:\"Status\"` should have been dropped from key=status, got: %s", string(out))
|
|
}
|
|
})
|
|
|
|
t.Run("preserves-custom-labels", func(t *testing.T) {
|
|
// label "When" for key "trigger" is NOT TitleCase(key) (which
|
|
// would be "Trigger"), so the custom label MUST be preserved.
|
|
in := []byte(`{"fields":[{"key":"trigger","label":"When","type":"select"}]}`)
|
|
out := trimRedundantSchemaLabels(in)
|
|
|
|
var parsed map[string][]map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("decode trimmed: %v", err)
|
|
}
|
|
labelRaw, ok := parsed["fields"][0]["label"]
|
|
if !ok {
|
|
t.Fatalf("custom label `When` dropped — would lose information; got: %s", string(out))
|
|
}
|
|
var label string
|
|
_ = json.Unmarshal(labelRaw, &label)
|
|
if label != "When" {
|
|
t.Errorf("custom label mutated: got %q, want %q", label, "When")
|
|
}
|
|
})
|
|
|
|
t.Run("multi-word-keys-titlecase-correctly", func(t *testing.T) {
|
|
// snake_case → "Title Case" with spaces. "due_date" → "Due Date".
|
|
in := []byte(`{"fields":[{"key":"due_date","label":"Due Date","type":"date"}]}`)
|
|
out := trimRedundantSchemaLabels(in)
|
|
|
|
var parsed map[string][]map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("decode trimmed: %v", err)
|
|
}
|
|
if _, ok := parsed["fields"][0]["label"]; ok {
|
|
t.Errorf("redundant `label:\"Due Date\"` should have been dropped from key=due_date, got: %s", string(out))
|
|
}
|
|
})
|
|
|
|
t.Run("malformed-schema-returns-raw", func(t *testing.T) {
|
|
// Defensive only — projectBootstrapCollection already gates on
|
|
// json.Valid() upstream, but trimRedundantSchemaLabels should
|
|
// never block a bootstrap response on a parse error.
|
|
bad := []byte(`{"fields":not valid json`)
|
|
out := trimRedundantSchemaLabels(bad)
|
|
if string(out) != string(bad) {
|
|
t.Errorf("malformed schema should pass through verbatim; got %q, want %q", string(out), string(bad))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestBootstrapIncludesPlaybookMetadata verifies that a seeded playbook
|
|
// item flows into the bootstrap's playbooks array with the right
|
|
// projection — slug, invocation_slug, has_arguments — without leaking
|
|
// the body (which is intentionally omitted from bootstrap for size).
|
|
func TestBootstrapIncludesPlaybookMetadata(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// Create a playbook with an invocation_slug + arguments.
|
|
createItem(t, srv, slug, "playbooks", map[string]interface{}{
|
|
"title": "Test playbook",
|
|
"content": "First paragraph — used as the summary.\n\nSecond paragraph (ignored).",
|
|
"fields": `{"status":"active","trigger":"manual","invocation_slug":"test-bp","arguments":[{"name":"target","type":"ref","required":true}]}`,
|
|
})
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
|
|
var found *AgentBootstrapPlaybookMeta
|
|
for i := range b.Playbooks {
|
|
if b.Playbooks[i].InvocationSlug == "test-bp" {
|
|
found = &b.Playbooks[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatalf("bootstrap.playbooks missing test-bp; got %+v", b.Playbooks)
|
|
}
|
|
if found.Title != "Test playbook" {
|
|
t.Errorf("playbook.title = %q, want %q", found.Title, "Test playbook")
|
|
}
|
|
if !found.HasArguments {
|
|
t.Error("playbook.has_arguments = false, want true (arguments list was non-empty)")
|
|
}
|
|
if found.Summary == "" {
|
|
t.Error("playbook.summary empty; expected the first body paragraph")
|
|
}
|
|
}
|
|
|
|
// TestBootstrapConventionIndex verifies the convention_index (TASK-2004):
|
|
// EVERY active convention appears as body-less metadata (ref/title/trigger),
|
|
// including the triggered set that never surfaced before, while the
|
|
// always-on bodies still ship in `conventions`. The load-bearing negative
|
|
// assertion is that no index entry carries a `content` body — the whole
|
|
// point is that triggered conventions are DISCOVERABLE without their bodies
|
|
// flooding the bootstrap.
|
|
func TestBootstrapConventionIndex(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
|
|
// One always-on (body ships in `conventions`) + two triggered
|
|
// (previously invisible — only the index makes them discoverable).
|
|
createItem(t, srv, slug, "conventions", map[string]interface{}{
|
|
"title": "Always follow this",
|
|
"content": "This body ships in full under bootstrap.conventions.",
|
|
"fields": `{"status":"active","trigger":"always","priority":"must","scope":"all"}`,
|
|
})
|
|
createItem(t, srv, slug, "conventions", map[string]interface{}{
|
|
"title": "On implement, run the suite",
|
|
"content": "Long triggered-convention body that must NOT leak into the index.",
|
|
"fields": `{"status":"active","trigger":"on-implement","priority":"should","scope":"all"}`,
|
|
})
|
|
createItem(t, srv, slug, "conventions", map[string]interface{}{
|
|
"title": "On PR create, request review",
|
|
"content": "Another triggered body that stays out of the index.",
|
|
"fields": `{"status":"active","trigger":"on-pr-create","priority":"should","scope":"all"}`,
|
|
})
|
|
// A disabled convention must NOT appear — index is active-only.
|
|
createItem(t, srv, slug, "conventions", map[string]interface{}{
|
|
"title": "Retired convention",
|
|
"content": "Disabled; should be absent from the index.",
|
|
"fields": `{"status":"disabled","trigger":"on-implement","priority":"nice-to-have","scope":"all"}`,
|
|
})
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var b AgentBootstrap
|
|
parseJSON(t, rr, &b)
|
|
|
|
byTitle := map[string]AgentBootstrapConventionMeta{}
|
|
for _, m := range b.ConventionIndex {
|
|
byTitle[m.Title] = m
|
|
}
|
|
|
|
// All three active conventions (always + both triggered) present.
|
|
for _, want := range []struct{ title, trigger string }{
|
|
{"Always follow this", "always"},
|
|
{"On implement, run the suite", "on-implement"},
|
|
{"On PR create, request review", "on-pr-create"},
|
|
} {
|
|
m, ok := byTitle[want.title]
|
|
if !ok {
|
|
t.Errorf("convention_index missing %q; got %+v", want.title, b.ConventionIndex)
|
|
continue
|
|
}
|
|
if m.Trigger != want.trigger {
|
|
t.Errorf("convention_index[%q].trigger = %q, want %q", want.title, m.Trigger, want.trigger)
|
|
}
|
|
if m.Ref == "" {
|
|
t.Errorf("convention_index[%q].ref is empty", want.title)
|
|
}
|
|
}
|
|
|
|
// Archived convention absent.
|
|
if _, ok := byTitle["Retired convention"]; ok {
|
|
t.Error("convention_index included an archived convention; want active-only")
|
|
}
|
|
|
|
// Load-bearing: the index carries NO bodies. Re-marshal and assert
|
|
// the raw JSON of each index entry has no `content` key at all.
|
|
raw := struct {
|
|
ConventionIndex []map[string]json.RawMessage `json:"convention_index"`
|
|
}{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("decode raw convention_index: %v", err)
|
|
}
|
|
if len(raw.ConventionIndex) == 0 {
|
|
t.Fatal("convention_index serialized empty")
|
|
}
|
|
for _, entry := range raw.ConventionIndex {
|
|
if _, has := entry["content"]; has {
|
|
t.Errorf("convention_index entry carries a `content` body; index must be metadata-only: %v", entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// bootstrapSizeBudget is the maximum byte count tolerated for the bootstrap
|
|
// JSON response against the seeded fixture in seedBootstrapSizeFixture.
|
|
//
|
|
// PLAN-1410 background: on the docapp production workspace, `pad bootstrap
|
|
// --format json` returns ~52,000 bytes / ~13,000 tokens, and the agent
|
|
// skill loads this blob on every `/pad` invocation. The plan trims the
|
|
// payload in stages (slim collection projection → drop duplicate
|
|
// recent_activity → cap dashboard arrays → drop convention slug → bump
|
|
// ToolSurfaceVersion to 0.4). This budget is the in-test ratchet that
|
|
// keeps later shape-change PRs honest: each one tightens this constant
|
|
// once the win is measured against the fixture.
|
|
//
|
|
// Budget history (each line is a PLAN-1410 PR that landed a win):
|
|
//
|
|
// TASK-1411 — 16 KiB (baseline benchmark added; fixture at 13,861 bytes)
|
|
// TASK-1412 — 11 KiB (slim BootstrapCollection projection; fixture at 8,992 bytes — collections section dropped from 8,848 to 3,979 bytes)
|
|
// TASK-1413 — 8 KiB (dedup top-level recent_activity, drop convention slug, cap dashboard.attention/recent_activity to 5 with overflow counts; fixture at 6,355 bytes — total dropped another 2,637 bytes)
|
|
// TASK-1417 — 7 KiB (close-out: bootstrap shape work complete, fixture still at 6,355 bytes; locks in the cumulative -54.2% win with ~12.8% headroom for routine schema reordering)
|
|
// TASK-1422 — 9 KiB (extend dashboard caps to active_items/active_plans/by_role; fixture grew to 7,823 bytes after seeding 6 in_progress tasks to exercise the new active_items cap — the growth is fixture-side, not shape-side, and the new caps demonstrably trigger in the per-section breakdown. suggested_next deliberately excluded — already capped to 3 upstream in buildDashboardResponse.)
|
|
//
|
|
// Note that the TASK-1422 budget loosening is purely fixture-side: the
|
|
// fixture deliberately seeds more `in_progress` items so the
|
|
// active_items cap fires under realistic load. The cap itself is
|
|
// purely a SAVINGS (clamps unbounded growth in live workspaces). On
|
|
// docapp the cap drops active_items from 7 → 5 entries.
|
|
//
|
|
// The constant intentionally lives next to the test that consumes it
|
|
// so PRs touching the bootstrap shape see the budget in the diff.
|
|
const bootstrapSizeBudget = 9 * 1024
|
|
|
|
// TestBootstrapSizeBudget locks in a payload-size budget for the
|
|
// bootstrap response so future regressions are caught at PR time.
|
|
//
|
|
// The fixture approximates a small-but-realistic workspace: the
|
|
// default template seeds plus a handful of always-on conventions with
|
|
// realistic bodies, one slug-invocable playbook, and a spread of items
|
|
// across collections to populate the dashboard. Production workspaces
|
|
// (docapp had ~52KB at PLAN-1410's measurement) will exceed this
|
|
// fixture's bytes — but the contributors to the payload (per-collection
|
|
// schema/settings shape, per-convention body, dashboard caps,
|
|
// duplicated recent_activity) are exercised proportionally here, so a
|
|
// shape-side regression shows up at fixture scale.
|
|
//
|
|
// On failure, the per-section breakdown is logged so the regression's
|
|
// origin is obvious without re-running locally.
|
|
func TestBootstrapSizeBudget(t *testing.T) {
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
seedBootstrapSizeFixture(t, srv, slug)
|
|
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
total := rr.Body.Len()
|
|
|
|
var b AgentBootstrap
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &b); err != nil {
|
|
t.Fatalf("decode bootstrap for breakdown: %v", err)
|
|
}
|
|
breakdown := bootstrapSectionBytes(b)
|
|
|
|
// Always log the breakdown so passing runs leave a measurable
|
|
// audit trail in CI output — that's how we track the trend as
|
|
// PLAN-1410's shape-change PRs land.
|
|
t.Logf("bootstrap size: %d bytes (budget %d)", total, bootstrapSizeBudget)
|
|
for _, line := range breakdown {
|
|
t.Logf(" %s", line)
|
|
}
|
|
|
|
if total > bootstrapSizeBudget {
|
|
t.Errorf("bootstrap size %d bytes exceeds budget %d bytes — see per-section breakdown above. "+
|
|
"If this is an intentional growth, raise bootstrapSizeBudget with a comment explaining why.",
|
|
total, bootstrapSizeBudget)
|
|
}
|
|
}
|
|
|
|
// seedBootstrapSizeFixture populates the workspace with the contributors
|
|
// the bootstrap size benchmark wants to exercise: always-on conventions
|
|
// with body content, a slug-invocable playbook (the user-callable shape),
|
|
// and a handful of items across collections to populate the dashboard.
|
|
//
|
|
// Keep this in sync with bootstrapSizeBudget — adding content here
|
|
// without raising the budget will fail TestBootstrapSizeBudget.
|
|
func seedBootstrapSizeFixture(t *testing.T, srv *Server, wsSlug string) {
|
|
t.Helper()
|
|
|
|
createItem(t, srv, wsSlug, "conventions", map[string]interface{}{
|
|
"title": "Run tests before commit",
|
|
"content": "Run `make check` before every commit. CI runs the same suite locally; catching failures here saves a round-trip and keeps main green.",
|
|
"fields": `{"status":"active","trigger":"always","priority":"must","scope":"all"}`,
|
|
})
|
|
createItem(t, srv, wsSlug, "conventions", map[string]interface{}{
|
|
"title": "Prefer composition over inheritance",
|
|
"content": "When extending behavior, embed and compose rather than subclass. Easier to test, easier to refactor when requirements change, no surprise dispatch.",
|
|
"fields": `{"status":"active","trigger":"always","priority":"should","scope":"backend"}`,
|
|
})
|
|
|
|
createItem(t, srv, wsSlug, "playbooks", map[string]interface{}{
|
|
"title": "Cut a release",
|
|
"content": "Release the next version.\n\n## Arguments\n\n- version (string, required) — semver, e.g. 0.5.0\n\n## Steps\n\n1. Verify clean tree on main\n2. Tag and push\n3. Verify CI release workflow",
|
|
"fields": `{"status":"active","trigger":"manual","invocation_slug":"release","arguments":[{"name":"version","type":"string","required":true}]}`,
|
|
})
|
|
|
|
// 6 in-progress tasks: 6 > bootstrapActiveItemsCap (5) → exercises
|
|
// the active_items cap in the fixture, producing an "active_items
|
|
// capped: 5 shown, 1 overflow" line in the per-section breakdown.
|
|
// in_progress (not open) because dashboard.active_items filters on
|
|
// isActiveStatus(), which excludes initial/terminal statuses.
|
|
for i := 0; i < 6; i++ {
|
|
createItem(t, srv, wsSlug, "tasks", map[string]interface{}{
|
|
"title": fmt.Sprintf("Sample task %d", i),
|
|
"content": "Task body — placeholder content to give the dashboard something to summarize.",
|
|
"fields": `{"status":"in-progress","priority":"medium"}`,
|
|
})
|
|
}
|
|
createItem(t, srv, wsSlug, "plans", map[string]interface{}{
|
|
"title": "Sample plan",
|
|
"content": "An active plan with a few children. The dashboard counts it as active work.",
|
|
"fields": `{"status":"active"}`,
|
|
})
|
|
}
|
|
|
|
// bootstrapSectionBytes returns a per-section byte breakdown of a
|
|
// bootstrap blob. Marshalled with the same encoder behavior as the
|
|
// real handler (compact, no indent) so the per-section totals sum
|
|
// close to the overall response body length, modulo the top-level
|
|
// JSON object's structural overhead.
|
|
//
|
|
// Diagnostic only — not part of any production contract. Sized for
|
|
// readability in `go test -v` output.
|
|
func bootstrapSectionBytes(b AgentBootstrap) []string {
|
|
lines := []string{
|
|
fmt.Sprintf("workspace: %d bytes", jsonLen(b.Workspace)),
|
|
fmt.Sprintf("user: %d bytes", jsonLen(b.User)),
|
|
fmt.Sprintf("collections: %d bytes (%d items)", jsonLen(b.Collections), len(b.Collections)),
|
|
fmt.Sprintf("conventions: %d bytes (%d items)", jsonLen(b.Conventions), len(b.Conventions)),
|
|
fmt.Sprintf("conv_index: %d bytes (%d items)", jsonLen(b.ConventionIndex), len(b.ConventionIndex)),
|
|
fmt.Sprintf("roles: %d bytes (%d items)", jsonLen(b.Roles), len(b.Roles)),
|
|
fmt.Sprintf("playbooks: %d bytes (%d items)", jsonLen(b.Playbooks), len(b.Playbooks)),
|
|
fmt.Sprintf("dashboard: %d bytes", jsonLen(b.Dashboard)),
|
|
}
|
|
// Surface the caps' effect when triggered so the trim's value is
|
|
// legible from CI output as PLAN-1410's PRs land. Each line follows
|
|
// the same shape: "<array> capped: N shown, M overflow".
|
|
if b.Dashboard != nil {
|
|
type capLine struct {
|
|
name string
|
|
shown int
|
|
overflow int
|
|
}
|
|
for _, c := range []capLine{
|
|
{"attention", len(b.Dashboard.Attention), b.Dashboard.AttentionOverflowCount},
|
|
{"recent_activity", len(b.Dashboard.RecentActivity), b.Dashboard.RecentActivityOverflowCount},
|
|
{"active_items", len(b.Dashboard.ActiveItems), b.Dashboard.ActiveItemsOverflowCount},
|
|
{"active_plans", len(b.Dashboard.ActivePlans), b.Dashboard.ActivePlansOverflowCount},
|
|
{"by_role", len(b.Dashboard.ByRole), b.Dashboard.ByRoleOverflowCount},
|
|
} {
|
|
if c.overflow > 0 {
|
|
lines = append(lines, fmt.Sprintf(
|
|
" └─ %s capped: %d shown, %d overflow",
|
|
c.name, c.shown, c.overflow))
|
|
}
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// jsonLen marshals v to compact JSON and returns the byte count. Test
|
|
// helper; errors are squashed because they'd indicate a programming
|
|
// error in the test (un-marshalable value) and we want the
|
|
// per-section breakdown to render even if one slice fails to encode.
|
|
func jsonLen(v interface{}) int {
|
|
out, err := json.Marshal(v)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
return len(out)
|
|
}
|
|
|
|
// TestCapBootstrapDashboard isolates the bootstrap dashboard cap logic
|
|
// from the rest of the bootstrap pipeline so the contract (cap to N per
|
|
// array, surface overflow count, leave the source pointer untouched)
|
|
// doesn't drift silently as new caps are added.
|
|
//
|
|
// PLAN-1410 introduced caps on attention + recent_activity (TASK-1413)
|
|
// and extended them to active_items / active_plans / by_role
|
|
// (TASK-1422, absorbing IDEA-1421). `suggested_next` is deliberately
|
|
// excluded — it's already capped to 3 upstream in buildDashboardResponse,
|
|
// making a bootstrap-side cap unreachable in production. This test
|
|
// covers all five live caps under the same contract.
|
|
func TestCapBootstrapDashboard(t *testing.T) {
|
|
// dashCounts is the per-array size knob the test uses to construct
|
|
// a DashboardResponse with arbitrary fill levels. Each field can be
|
|
// set independently so subtests can exercise specific caps without
|
|
// populating the others — keeps the assertions for any one cap
|
|
// uncoupled from the noise of the others.
|
|
type dashCounts struct {
|
|
Att, Rec, Items, Plans, Role int
|
|
}
|
|
mk := func(c dashCounts) *DashboardResponse {
|
|
d := &DashboardResponse{
|
|
Attention: make([]DashboardAttention, c.Att),
|
|
RecentActivity: make([]DashboardActivity, c.Rec),
|
|
ActiveItems: make([]DashboardActiveItem, c.Items),
|
|
ActivePlans: make([]DashboardPlan, c.Plans),
|
|
ByRole: make([]store.RoleBreakdown, c.Role),
|
|
}
|
|
// Populate with identifying values so the cap's slice header
|
|
// retains a defined order — easier to spot index-shifting bugs.
|
|
for i := range d.Attention {
|
|
d.Attention[i] = DashboardAttention{Type: "stalled", ItemRef: fmt.Sprintf("TASK-%d", i)}
|
|
}
|
|
for i := range d.RecentActivity {
|
|
d.RecentActivity[i] = DashboardActivity{Action: "updated", ItemSlug: fmt.Sprintf("item-%d", i)}
|
|
}
|
|
for i := range d.ActiveItems {
|
|
d.ActiveItems[i] = DashboardActiveItem{Slug: fmt.Sprintf("active-%d", i)}
|
|
}
|
|
for i := range d.ActivePlans {
|
|
d.ActivePlans[i] = DashboardPlan{Slug: fmt.Sprintf("plan-%d", i)}
|
|
}
|
|
for i := range d.ByRole {
|
|
d.ByRole[i] = store.RoleBreakdown{}
|
|
}
|
|
return d
|
|
}
|
|
|
|
t.Run("under-cap-no-overflow", func(t *testing.T) {
|
|
d := mk(dashCounts{Att: 2, Rec: 3, Items: 1, Plans: 1, Role: 2})
|
|
out := capBootstrapDashboard(d)
|
|
if out == nil {
|
|
t.Fatal("expected non-nil result even when nothing trimmed")
|
|
}
|
|
assertLen(t, "attention", len(out.Attention), 2)
|
|
assertOverflow(t, "attention", out.AttentionOverflowCount, 0)
|
|
assertLen(t, "recent_activity", len(out.RecentActivity), 3)
|
|
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 0)
|
|
assertLen(t, "active_items", len(out.ActiveItems), 1)
|
|
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 0)
|
|
assertLen(t, "active_plans", len(out.ActivePlans), 1)
|
|
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 0)
|
|
assertLen(t, "by_role", len(out.ByRole), 2)
|
|
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 0)
|
|
})
|
|
|
|
t.Run("over-cap-truncates-and-counts-overflow", func(t *testing.T) {
|
|
d := mk(dashCounts{
|
|
Att: bootstrapAttentionCap + 8,
|
|
Rec: bootstrapRecentActivityCap + 3,
|
|
Items: bootstrapActiveItemsCap + 4,
|
|
Plans: bootstrapActivePlansCap + 2,
|
|
Role: bootstrapByRoleCap + 1,
|
|
})
|
|
out := capBootstrapDashboard(d)
|
|
assertLen(t, "attention", len(out.Attention), bootstrapAttentionCap)
|
|
assertOverflow(t, "attention", out.AttentionOverflowCount, 8)
|
|
assertLen(t, "recent_activity", len(out.RecentActivity), bootstrapRecentActivityCap)
|
|
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 3)
|
|
assertLen(t, "active_items", len(out.ActiveItems), bootstrapActiveItemsCap)
|
|
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 4)
|
|
assertLen(t, "active_plans", len(out.ActivePlans), bootstrapActivePlansCap)
|
|
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 2)
|
|
assertLen(t, "by_role", len(out.ByRole), bootstrapByRoleCap)
|
|
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 1)
|
|
})
|
|
|
|
t.Run("source-pointer-unchanged", func(t *testing.T) {
|
|
// Defensive contract: callers downstream of buildDashboardResponse
|
|
// (the dashboard endpoint itself) must see their full-length
|
|
// arrays. The cap mutates a shallow copy.
|
|
d := mk(dashCounts{
|
|
Att: bootstrapAttentionCap + 5,
|
|
Rec: bootstrapRecentActivityCap + 5,
|
|
Items: bootstrapActiveItemsCap + 5,
|
|
Plans: bootstrapActivePlansCap + 5,
|
|
Role: bootstrapByRoleCap + 5,
|
|
})
|
|
want := struct{ att, rec, items, plans, role int }{
|
|
att: len(d.Attention),
|
|
rec: len(d.RecentActivity),
|
|
items: len(d.ActiveItems),
|
|
plans: len(d.ActivePlans),
|
|
role: len(d.ByRole),
|
|
}
|
|
|
|
_ = capBootstrapDashboard(d)
|
|
|
|
if got := len(d.Attention); got != want.att {
|
|
t.Errorf("source Attention mutated: len = %d, want %d", got, want.att)
|
|
}
|
|
if got := len(d.RecentActivity); got != want.rec {
|
|
t.Errorf("source RecentActivity mutated: len = %d, want %d", got, want.rec)
|
|
}
|
|
if got := len(d.ActiveItems); got != want.items {
|
|
t.Errorf("source ActiveItems mutated: len = %d, want %d", got, want.items)
|
|
}
|
|
if got := len(d.ActivePlans); got != want.plans {
|
|
t.Errorf("source ActivePlans mutated: len = %d, want %d", got, want.plans)
|
|
}
|
|
if got := len(d.ByRole); got != want.role {
|
|
t.Errorf("source ByRole mutated: len = %d, want %d", got, want.role)
|
|
}
|
|
})
|
|
|
|
t.Run("exact-cap-no-overflow", func(t *testing.T) {
|
|
// Boundary: len == cap should not flag overflow.
|
|
d := mk(dashCounts{
|
|
Att: bootstrapAttentionCap,
|
|
Rec: bootstrapRecentActivityCap,
|
|
Items: bootstrapActiveItemsCap,
|
|
Plans: bootstrapActivePlansCap,
|
|
Role: bootstrapByRoleCap,
|
|
})
|
|
out := capBootstrapDashboard(d)
|
|
assertOverflow(t, "attention", out.AttentionOverflowCount, 0)
|
|
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 0)
|
|
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 0)
|
|
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 0)
|
|
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 0)
|
|
})
|
|
}
|
|
|
|
// assertLen and assertOverflow are tiny helpers used by
|
|
// TestCapBootstrapDashboard to keep the per-array assertion noise from
|
|
// drowning the actual contract being tested. Both call t.Helper() so
|
|
// failure lines point at the calling subtest, not at this file.
|
|
func assertLen(t *testing.T, name string, got, want int) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Errorf("%s len = %d, want %d", name, got, want)
|
|
}
|
|
}
|
|
|
|
func assertOverflow(t *testing.T, name string, got, want int) {
|
|
t.Helper()
|
|
if got != want {
|
|
t.Errorf("%s_overflow_count = %d, want %d", name, got, want)
|
|
}
|
|
}
|
|
|
|
// TestBootstrapDashboardCarriesDegraded is the BUG-2072 regression guard.
|
|
// BUG-2014 (PR #867) added Degraded + DegradedSections to DashboardResponse
|
|
// so callers can tell a failed sub-query apart from a genuinely-empty
|
|
// section. BootstrapDashboard embeds *DashboardResponse anonymously, so
|
|
// encoding/json promotes both fields into the bootstrap wire shape — the
|
|
// MCP `pad_meta.action: bootstrap`, the `pad://workspace/{ws}/bootstrap`
|
|
// resource, and the `pad_set_workspace` embed all serialize this same
|
|
// struct, so the partial-failure state reaches every agent surface.
|
|
//
|
|
// This test pins that promotion. It asserts on the MARSHALED JSON (not
|
|
// just promoted field access) because the wire contract is what agents
|
|
// consume: if a future refactor ever converts BootstrapDashboard to an
|
|
// explicit slim projection (as BootstrapCollection / BootstrapRole
|
|
// already are) and forgets to carry these two fields, the JSON assertion
|
|
// fails even though `out.Degraded` would still compile.
|
|
func TestBootstrapDashboardCarriesDegraded(t *testing.T) {
|
|
t.Run("degraded-flows-through", func(t *testing.T) {
|
|
d := &DashboardResponse{
|
|
Degraded: true,
|
|
DegradedSections: []string{"recent_activity", "by_role"},
|
|
}
|
|
out := capBootstrapDashboard(d)
|
|
|
|
// Promoted field access (compile-time guarantee of the embedding).
|
|
if !out.Degraded {
|
|
t.Error("BootstrapDashboard.Degraded = false, want true")
|
|
}
|
|
if !reflect.DeepEqual(out.DegradedSections, []string{"recent_activity", "by_role"}) {
|
|
t.Errorf("BootstrapDashboard.DegradedSections = %v, want [recent_activity by_role]", out.DegradedSections)
|
|
}
|
|
|
|
// Wire contract: the fields must survive JSON marshaling with the
|
|
// same tag names the full /dashboard endpoint uses.
|
|
var wire struct {
|
|
Degraded bool `json:"degraded"`
|
|
DegradedSections []string `json:"degraded_sections"`
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
t.Fatalf("marshal bootstrap dashboard: %v", err)
|
|
}
|
|
if err := json.Unmarshal(b, &wire); err != nil {
|
|
t.Fatalf("unmarshal bootstrap dashboard: %v", err)
|
|
}
|
|
if !wire.Degraded {
|
|
t.Errorf("marshaled bootstrap dashboard missing degraded=true; got %s", b)
|
|
}
|
|
if !reflect.DeepEqual(wire.DegradedSections, []string{"recent_activity", "by_role"}) {
|
|
t.Errorf("marshaled degraded_sections = %v, want [recent_activity by_role]; got %s", wire.DegradedSections, b)
|
|
}
|
|
})
|
|
|
|
t.Run("healthy-dashboard-not-degraded", func(t *testing.T) {
|
|
out := capBootstrapDashboard(&DashboardResponse{})
|
|
if out.Degraded {
|
|
t.Error("healthy dashboard should not be degraded")
|
|
}
|
|
// degraded_sections carries omitempty, so a healthy dashboard must
|
|
// not emit the key at all.
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
t.Fatalf("marshal bootstrap dashboard: %v", err)
|
|
}
|
|
if got := string(b); strings.Contains(got, "degraded_sections") {
|
|
t.Errorf("healthy dashboard should omit degraded_sections; got %s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPlaybookSummaryPrefersFirstParagraph isolates the summary extraction
|
|
// from the bootstrap path so the rule (skip headings, take first non-empty
|
|
// paragraph, cap at ~240 chars) doesn't drift silently.
|
|
func TestPlaybookSummaryPrefersFirstParagraph(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{
|
|
name: "skips-headings",
|
|
body: "# Title\n\n## Overview\n\nThis is the first prose line.",
|
|
want: "This is the first prose line.",
|
|
},
|
|
{
|
|
name: "trims-leading-whitespace",
|
|
body: " Indented summary line.",
|
|
want: "Indented summary line.",
|
|
},
|
|
{
|
|
name: "empty-body",
|
|
body: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "headings-only",
|
|
body: "# A\n## B\n### C",
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := collections.PlaybookSummary(tc.body)
|
|
if got != tc.want {
|
|
t.Errorf("collections.PlaybookSummary() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Long bodies must be truncated. Verify capping puts an ellipsis on
|
|
// the end so callers can detect truncation visually.
|
|
long := ""
|
|
for i := 0; i < 100; i++ {
|
|
long += "abcdefghij"
|
|
}
|
|
got := collections.PlaybookSummary(long)
|
|
if len(got) > 240 {
|
|
t.Errorf("long summary not capped at 240 chars; got %d", len(got))
|
|
}
|
|
if got[len(got)-len("…"):] != "…" {
|
|
t.Errorf("truncated summary missing ellipsis: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBootstrap_AdminBearer_RestrictedMemberIsScoped pins BUG-1917 for the
|
|
// bootstrap endpoint (the agents' primary path — TASK-1379 has it call
|
|
// buildDashboardResponse directly). A platform admin who is only a
|
|
// restricted member (collection_access "specific") of a workspace must be
|
|
// scoped to their actual membership over a bearer token (PAT/CLI/OAuth),
|
|
// while a cookie session keeps the pre-existing unrestricted admin view.
|
|
// Mirrors TestDashboard_AdminBearer_RestrictedMemberIsScoped's fixture.
|
|
func TestBootstrap_AdminBearer_RestrictedMemberIsScoped(t *testing.T) {
|
|
srv := testServer(t)
|
|
|
|
admin, err := srv.store.CreateUser(models.UserCreate{
|
|
Email: "admin@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create admin: %v", err)
|
|
}
|
|
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "BootstrapBearer", OwnerID: admin.ID})
|
|
if err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "editor"); err != nil {
|
|
t.Fatalf("add member: %v", err)
|
|
}
|
|
|
|
schema := `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`
|
|
visible, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Visible", Slug: "visible", Prefix: "VIS", Schema: schema,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create visible collection: %v", err)
|
|
}
|
|
if _, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Hidden", Slug: "hidden", Prefix: "HID", Schema: schema,
|
|
}); err != nil {
|
|
t.Fatalf("create hidden collection: %v", err)
|
|
}
|
|
if err := srv.store.SetMemberCollectionAccess(ws.ID, admin.ID, "specific", []string{visible.ID}); err != nil {
|
|
t.Fatalf("set member collection access: %v", err)
|
|
}
|
|
|
|
tok, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{
|
|
Name: "admin-pat", WorkspaceID: ws.ID,
|
|
}, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("CreateAPIToken: %v", err)
|
|
}
|
|
|
|
hasCollection := func(bs AgentBootstrap, slug string) bool {
|
|
for _, c := range bs.Collections {
|
|
if c.Slug == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Bearer path — scoped: only the visible collection shows up.
|
|
rr := doRequestWithBearer(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/agent/bootstrap", tok.Token, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("bearer bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var bearerBS AgentBootstrap
|
|
parseJSON(t, rr, &bearerBS)
|
|
if hasCollection(bearerBS, "hidden") {
|
|
t.Errorf("bearer admin bootstrap leaked the hidden collection: %+v", bearerBS.Collections)
|
|
}
|
|
if !hasCollection(bearerBS, "visible") {
|
|
t.Errorf("bearer admin bootstrap missing the visible collection: %+v", bearerBS.Collections)
|
|
}
|
|
|
|
// Cookie path — unrestricted (the pre-existing web UI admin affordance).
|
|
sessTok, err := srv.store.CreateSession(admin.ID, "web-test", "192.0.2.1", "", webSessionTTL)
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/agent/bootstrap", nil, sessTok)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cookie bootstrap: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
var cookieBS AgentBootstrap
|
|
parseJSON(t, rr, &cookieBS)
|
|
if !hasCollection(cookieBS, "hidden") {
|
|
t.Errorf("cookie admin bootstrap should be unrestricted (see hidden collection); got %+v", cookieBS.Collections)
|
|
}
|
|
}
|