diff --git a/internal/server/handlers_bootstrap.go b/internal/server/handlers_bootstrap.go index 41af49a5..d848dc00 100644 --- a/internal/server/handlers_bootstrap.go +++ b/internal/server/handlers_bootstrap.go @@ -153,8 +153,8 @@ type AgentBootstrapPlaybookMeta struct { // BootstrapDashboard is the bootstrap-side dashboard projection. It // embeds *DashboardResponse so the wire shape stays compatible with the // `GET /dashboard` endpoint (same field names, same nesting), then adds -// two overflow counts that report how many entries were trimmed from -// the bootstrap's capped views. +// five overflow counts (one per capped sub-array) that report how many +// entries were trimmed from the bootstrap's capped views. // // Why a wrapper rather than mutating DashboardResponse: the cap is // bootstrap-only — `pad project dashboard` and the web UI's dashboard @@ -168,18 +168,38 @@ type BootstrapDashboard struct { // RecentActivityOverflowCount mirrors AttentionOverflowCount for the // recent_activity tail. RecentActivityOverflowCount int `json:"recent_activity_overflow_count,omitempty"` + // ActiveItemsOverflowCount, ActivePlansOverflowCount, and + // ByRoleOverflowCount cap the three other dashboard sub-arrays + // that grow with workspace state. Same semantics as the + // Attention/RecentActivity counts above: omitted when zero, + // populated with `len(original) - cap` when truncation kicked in. + // PLAN-1410 / TASK-1422 (absorbs IDEA-1421). + // + // `suggested_next` was deliberately NOT added to this set: + // buildDashboardResponse already truncates SuggestedNext to 3 + // upstream (see "Take top 3" in handlers_dashboard.go), so a + // bootstrap-side cap of 5 would be unreachable dead code. If the + // upstream cap is ever raised or removed, that's the moment to + // add a suggested_next_overflow_count here. + ActiveItemsOverflowCount int `json:"active_items_overflow_count,omitempty"` + ActivePlansOverflowCount int `json:"active_plans_overflow_count,omitempty"` + ByRoleOverflowCount int `json:"by_role_overflow_count,omitempty"` } -// bootstrapAttentionCap and bootstrapRecentActivityCap clamp the -// dashboard's `attention` and `recent_activity` arrays in the bootstrap +// Bootstrap caps clamp the per-array sizes in the bootstrap dashboard // projection. 5 is the practical surfacing depth for an agent greeting // or status pass — anything beyond is too much for a single response // to render conversationally; the agent should pivot to the full -// `pad project dashboard` query when the overflow count signals more -// work to consider. PLAN-1410. +// `pad project dashboard` query when an overflow count signals more +// work to consider. PLAN-1410. The first two land in TASK-1413; the +// remaining three (active_items / active_plans / by_role) are TASK-1422 +// (IDEA-1421 absorbed). suggested_next is excluded — upstream cap of 3. const ( bootstrapAttentionCap = 5 bootstrapRecentActivityCap = 5 + bootstrapActiveItemsCap = 5 + bootstrapActivePlansCap = 5 + bootstrapByRoleCap = 5 ) // isCollectionSlugVisible reports whether the named collection survived @@ -375,8 +395,10 @@ func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *h // Dashboard — recreate via the existing handler logic if a request // context is available, then wrap in BootstrapDashboard so the - // bootstrap-side cap on `attention` and `recent_activity` (with - // overflow counts) doesn't leak into the `GET /dashboard` contract. + // bootstrap-side caps on `attention`, `recent_activity`, + // `active_items`, `active_plans`, and `by_role` (each with its + // `*_overflow_count` companion) don't leak into the + // `GET /dashboard` contract. if r != nil { dash, derr := s.buildDashboardResponse(workspaceID, r) if derr == nil && dash != nil { @@ -577,7 +599,7 @@ func trimLeadingSpaces(s string) string { // arrays unchanged. The slice backing arrays are shared — we only // trim the view, no allocation needed for the truncated portion. // -// Returns a non-nil *BootstrapDashboard even when both caps are +// Returns a non-nil *BootstrapDashboard even when all caps are // untriggered, so the agent always sees a consistent shape. func capBootstrapDashboard(d *DashboardResponse) *BootstrapDashboard { copied := *d @@ -590,6 +612,20 @@ func capBootstrapDashboard(d *DashboardResponse) *BootstrapDashboard { copied.RecentActivity = copied.RecentActivity[:bootstrapRecentActivityCap] out.RecentActivityOverflowCount = n } + if n := len(copied.ActiveItems) - bootstrapActiveItemsCap; n > 0 { + copied.ActiveItems = copied.ActiveItems[:bootstrapActiveItemsCap] + out.ActiveItemsOverflowCount = n + } + if n := len(copied.ActivePlans) - bootstrapActivePlansCap; n > 0 { + copied.ActivePlans = copied.ActivePlans[:bootstrapActivePlansCap] + out.ActivePlansOverflowCount = n + } + if n := len(copied.ByRole) - bootstrapByRoleCap; n > 0 { + copied.ByRole = copied.ByRole[:bootstrapByRoleCap] + out.ByRoleOverflowCount = n + } + // SuggestedNext intentionally NOT capped here — see godoc on + // BootstrapDashboard. return out } diff --git a/internal/server/handlers_bootstrap_test.go b/internal/server/handlers_bootstrap_test.go index 5e4c55cf..5736c843 100644 --- a/internal/server/handlers_bootstrap_test.go +++ b/internal/server/handlers_bootstrap_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "testing" + + "github.com/PerpetualSoftware/pad/internal/store" ) // TestBootstrapEmptyWorkspace verifies the bootstrap blob returns the @@ -151,12 +153,17 @@ func TestBootstrapIncludesPlaybookMetadata(t *testing.T) { // 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.) // -// PLAN-1410 followups land their own wins under their own budget -// ratchets (e.g. IDEA-1421's dashboard sub-array caps). 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 = 7 * 1024 +// 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. @@ -233,11 +240,16 @@ func seedBootstrapSizeFixture(t *testing.T, srv *Server, wsSlug string) { "fields": `{"status":"active","trigger":"manual","invocation_slug":"release","arguments":[{"name":"version","type":"string","required":true}]}`, }) - for i := 0; i < 5; i++ { + // 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":"open","priority":"medium"}`, + "fields": `{"status":"in-progress","priority":"medium"}`, }) } createItem(t, srv, wsSlug, "plans", map[string]interface{}{ @@ -266,17 +278,26 @@ func bootstrapSectionBytes(b AgentBootstrap) []string { 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 later PRs land. + // legible from CI output as PLAN-1410's PRs land. Each line follows + // the same shape: " capped: N shown, M overflow". if b.Dashboard != nil { - if b.Dashboard.AttentionOverflowCount > 0 { - lines = append(lines, fmt.Sprintf( - " └─ attention capped: %d shown, %d overflow", - len(b.Dashboard.Attention), b.Dashboard.AttentionOverflowCount)) + type capLine struct { + name string + shown int + overflow int } - if b.Dashboard.RecentActivityOverflowCount > 0 { - lines = append(lines, fmt.Sprintf( - " └─ recent_activity capped: %d shown, %d overflow", - len(b.Dashboard.RecentActivity), b.Dashboard.RecentActivityOverflowCount)) + 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 @@ -295,91 +316,166 @@ func jsonLen(v interface{}) int { } // TestCapBootstrapDashboard isolates the bootstrap dashboard cap logic -// from the rest of the bootstrap pipeline so the contract (cap to N, -// surface overflow count, leave the source pointer untouched) doesn't -// drift silently. PLAN-1410 / TASK-1413. +// 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) { - // Helper: make a DashboardResponse with N attention + M recent_activity. - mk := func(attN, recN int) *DashboardResponse { - attention := make([]DashboardAttention, attN) - for i := range attention { - attention[i] = DashboardAttention{Type: "stalled", ItemRef: fmt.Sprintf("TASK-%d", i)} + // 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), } - recent := make([]DashboardActivity, recN) - for i := range recent { - recent[i] = DashboardActivity{Action: "updated", ItemSlug: fmt.Sprintf("item-%d", i)} + // 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)} } - return &DashboardResponse{Attention: attention, RecentActivity: recent} + 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(2, 3) + 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") } - if got := len(out.Attention); got != 2 { - t.Errorf("attention len = %d, want 2 (under cap, no trim)", got) - } - if out.AttentionOverflowCount != 0 { - t.Errorf("attention_overflow_count = %d, want 0", out.AttentionOverflowCount) - } - if got := len(out.RecentActivity); got != 3 { - t.Errorf("recent_activity len = %d, want 3", got) - } - if out.RecentActivityOverflowCount != 0 { - t.Errorf("recent_activity_overflow_count = %d, want 0", out.RecentActivityOverflowCount) - } + 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(bootstrapAttentionCap+8, bootstrapRecentActivityCap+3) + d := mk(dashCounts{ + Att: bootstrapAttentionCap + 8, + Rec: bootstrapRecentActivityCap + 3, + Items: bootstrapActiveItemsCap + 4, + Plans: bootstrapActivePlansCap + 2, + Role: bootstrapByRoleCap + 1, + }) out := capBootstrapDashboard(d) - if got := len(out.Attention); got != bootstrapAttentionCap { - t.Errorf("attention len = %d, want %d (capped)", got, bootstrapAttentionCap) - } - if out.AttentionOverflowCount != 8 { - t.Errorf("attention_overflow_count = %d, want 8", out.AttentionOverflowCount) - } - if got := len(out.RecentActivity); got != bootstrapRecentActivityCap { - t.Errorf("recent_activity len = %d, want %d (capped)", got, bootstrapRecentActivityCap) - } - if out.RecentActivityOverflowCount != 3 { - t.Errorf("recent_activity_overflow_count = %d, want 3", out.RecentActivityOverflowCount) - } + 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(bootstrapAttentionCap+5, bootstrapRecentActivityCap+5) - origAttLen := len(d.Attention) - origRecLen := len(d.RecentActivity) + 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 != origAttLen { - t.Errorf("source Attention mutated: len = %d, want %d", got, origAttLen) + 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 != origRecLen { - t.Errorf("source RecentActivity mutated: len = %d, want %d", got, origRecLen) + 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(bootstrapAttentionCap, bootstrapRecentActivityCap) + d := mk(dashCounts{ + Att: bootstrapAttentionCap, + Rec: bootstrapRecentActivityCap, + Items: bootstrapActiveItemsCap, + Plans: bootstrapActivePlansCap, + Role: bootstrapByRoleCap, + }) out := capBootstrapDashboard(d) - if out.AttentionOverflowCount != 0 { - t.Errorf("attention_overflow_count at exact cap = %d, want 0", out.AttentionOverflowCount) - } - if out.RecentActivityOverflowCount != 0 { - t.Errorf("recent_activity_overflow_count at exact cap = %d, want 0", out.RecentActivityOverflowCount) - } + 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) + } +} + // 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. diff --git a/skills/pad/SKILL.md b/skills/pad/SKILL.md index df0d060b..cbfc63b4 100644 --- a/skills/pad/SKILL.md +++ b/skills/pad/SKILL.md @@ -35,7 +35,7 @@ The returned `AgentBootstrap` blob carries everything the skill needs to start a - `conventions [...]` — full bodies of `trigger=always, status=active` items. **Must-follow project rules.** - `roles [...]` — agent roles configured in the workspace - `playbooks [...]` — METADATA ONLY: `ref`, `title`, `slug`, `invocation_slug`, `trigger`, `scope`, `status`, `has_arguments`, `summary`. Full bodies load on invocation via `pad playbook show `. -- `dashboard {...}` — active items, attention, suggested next, recent activity. `attention` and `recent_activity` are capped to 5 entries each; `attention_overflow_count` and `recent_activity_overflow_count` report how many were trimmed (use `pad project dashboard` to pull the full set when overflow > 0). +- `dashboard {...}` — active items, attention, suggested next, recent activity. Five sub-arrays are capped to 5 entries each (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`); each pairs with a `_overflow_count` int field surfaced when truncation kicked in. Use `pad project dashboard` to pull the full set when any overflow > 0. If the conventions list includes items, treat them as project rules you must follow. The vocabulary depends on the workspace domain — a software workspace ships rules like "use conventional commit format," a hiring workspace ships rules like "anonymize candidate names in exports," a research workspace ships rules like "always cite sources." Follow whatever the workspace has configured.