mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +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
877 lines
35 KiB
Go
877 lines
35 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// AgentBootstrap is the single response struct returned by the bootstrap
|
|
// endpoint. It consolidates the four /pad context-loading calls (workspace,
|
|
// collections, conventions, roles + playbooks) the agent skill used to make
|
|
// at every invocation into one round-trip — roughly 200-400ms saved on every
|
|
// /pad command.
|
|
//
|
|
// PLAN-1377 TASK-1379. Same struct is exposed via three MCP surfaces in
|
|
// TASK-1380: a `pad://workspace/{ws}/bootstrap` resource, an embedded blob
|
|
// in `pad_set_workspace`'s response, and an on-demand `pad_meta.action=bootstrap`
|
|
// refresh.
|
|
type AgentBootstrap struct {
|
|
Workspace AgentBootstrapWorkspace `json:"workspace"`
|
|
User AgentBootstrapUser `json:"user"`
|
|
Collections []BootstrapCollection `json:"collections"`
|
|
Conventions []AgentBootstrapConvention `json:"conventions"`
|
|
// ConventionIndex is the metadata-only catalog of EVERY active
|
|
// convention (all triggers, including the always-on ones whose full
|
|
// bodies also appear in Conventions). It carries no bodies — just
|
|
// ref/title/trigger/role — so an agent can see that e.g. 10
|
|
// `on-implement` conventions exist and fire the targeted
|
|
// `pad item list conventions --field trigger=on-implement` query only
|
|
// when that trigger is relevant, instead of loading every body up
|
|
// front or (as before) never learning the triggered set exists.
|
|
// Mirrors the Playbooks metadata pattern. TASK-2004.
|
|
ConventionIndex []AgentBootstrapConventionMeta `json:"convention_index"`
|
|
Roles []BootstrapRole `json:"roles"`
|
|
Playbooks []AgentBootstrapPlaybookMeta `json:"playbooks"`
|
|
Dashboard *BootstrapDashboard `json:"dashboard,omitempty"`
|
|
// NeedsOnboarding is true when the workspace has ZERO user-created
|
|
// items — i.e. nothing beyond what SeedCollectionsFromTemplate seeded
|
|
// at init time. The agent skill reads this on every /pad invocation
|
|
// and renders a "this workspace hasn't been set up yet — say /pad
|
|
// onboard" nudge while it's true; the moment any user (or agent on
|
|
// their behalf) creates the first real item, the flag flips to false
|
|
// and the nudge disappears. PLAN-1496 / TASK-1504.
|
|
//
|
|
// Computed per-request (not stored). The store predicate is "any
|
|
// item with source != 'template'" — see
|
|
// Store.WorkspaceHasUserCreatedItems for the rationale on why we
|
|
// exclude template seeds rather than enumerating user-side source
|
|
// values.
|
|
NeedsOnboarding bool `json:"needs_onboarding"`
|
|
}
|
|
|
|
// BootstrapCollection is the lightweight collection projection delivered
|
|
// in the agent bootstrap response. Distinct from models.Collection: drops
|
|
// fields the agent never reads (id, workspace_id, created_at, updated_at,
|
|
// deleted_at) and the web-UI `settings` blob (quick-action prompts,
|
|
// default views, group-by hints) so the per-invocation payload stays
|
|
// tight. The `schema` field is delivered as a nested JSON object rather
|
|
// than a JSON-encoded string so the agent sees real `{}`/`[]` structure
|
|
// instead of backslash-escaped quotes — roughly 25% byte reduction on
|
|
// the schema field alone, more when escape-heavy.
|
|
//
|
|
// Fields preserved are exactly what the /pad skill consumes:
|
|
// - slug / name / prefix / icon / description — addressing + listing
|
|
// - schema — drives `pad item create/update --field key=value` validation
|
|
// - item_count / active_item_count — surface-area counts in greetings
|
|
// - is_default / is_system — distinguish template seeds from custom collections
|
|
// - sort_order — preserves the workspace's authored ordering
|
|
//
|
|
// PLAN-1410 / TASK-1412. Pairs with the bootstrapSizeBudget benchmark
|
|
// added in TASK-1411 — landing this projection tightens the budget by
|
|
// ~25-30% of the collections section.
|
|
type BootstrapCollection struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Prefix string `json:"prefix"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Schema json.RawMessage `json:"schema,omitempty"`
|
|
SortOrder int `json:"sort_order,omitempty"`
|
|
IsDefault bool `json:"is_default"`
|
|
IsSystem bool `json:"is_system"`
|
|
ItemCount int `json:"item_count"`
|
|
ActiveItemCount int `json:"active_item_count"`
|
|
}
|
|
|
|
// projectBootstrapCollection converts a models.Collection into the slim
|
|
// bootstrap projection. The `schema` field is emitted as a nested JSON
|
|
// object when the stored string is valid JSON; an empty or malformed
|
|
// schema is omitted (omitempty + nil RawMessage) so the response never
|
|
// carries garbage that would break agent-side json.Unmarshal.
|
|
//
|
|
// json.Valid() is cheap (single byte-stream pass, no allocation) and
|
|
// guarantees the wire shape stays parseable even if a future migration
|
|
// or buggy write leaves a non-JSON value in the column. Defensive only:
|
|
// every collection created via the API today writes valid JSON.
|
|
//
|
|
// The schema bytes additionally go through trimRedundantSchemaLabels
|
|
// to drop fields where `label == TitleCase(key)` — added in TASK-1424
|
|
// to cut the schema sub-object's bytes by another ~10% (the labels
|
|
// that match the auto-fill rule carry no information the agent can't
|
|
// reconstruct).
|
|
func projectBootstrapCollection(c models.Collection) BootstrapCollection {
|
|
out := BootstrapCollection{
|
|
Slug: c.Slug,
|
|
Name: c.Name,
|
|
Prefix: c.Prefix,
|
|
Icon: c.Icon,
|
|
Description: c.Description,
|
|
SortOrder: c.SortOrder,
|
|
IsDefault: c.IsDefault,
|
|
IsSystem: c.IsSystem,
|
|
ItemCount: c.ItemCount,
|
|
ActiveItemCount: c.ActiveItemCount,
|
|
}
|
|
if s := strings.TrimSpace(c.Schema); s != "" && json.Valid([]byte(s)) {
|
|
out.Schema = trimRedundantSchemaLabels([]byte(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bootstrapSchema is the bootstrap-side schema shape, parallel to
|
|
// models.CollectionSchema. Used only by trimRedundantSchemaLabels to
|
|
// round-trip the schema bytes with `omitempty` on FieldDef.Label so
|
|
// redundant labels (label == TitleCase(key)) collapse out of the
|
|
// wire response. Custom labels — those that differ from the
|
|
// auto-fill rule, e.g. label="When" for key="trigger" — are preserved
|
|
// unchanged because their value is informationally distinct.
|
|
//
|
|
// PLAN-1410 / TASK-1424.
|
|
type bootstrapSchema struct {
|
|
Fields []bootstrapFieldDef `json:"fields"`
|
|
}
|
|
|
|
// bootstrapFieldDef mirrors models.FieldDef field-for-field with two
|
|
// differences: Label is `omitempty` (the whole point), and Default is
|
|
// json.RawMessage so we round-trip any default value verbatim without
|
|
// re-parsing.
|
|
//
|
|
// **MUST be kept in sync with models.FieldDef.** Adding a field to
|
|
// the canonical struct without mirroring here would silently drop
|
|
// the field from the bootstrap schema response.
|
|
// TestBootstrapFieldDefMirrorsModelsFieldDef is the drift detector —
|
|
// it uses reflection to assert structural parity and fails the build
|
|
// if the two diverge.
|
|
type bootstrapFieldDef struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label,omitempty"`
|
|
Type string `json:"type"`
|
|
Options []string `json:"options,omitempty"`
|
|
TerminalOptions []string `json:"terminal_options,omitempty"`
|
|
Default json.RawMessage `json:"default,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
Computed bool `json:"computed,omitempty"`
|
|
Collection string `json:"collection,omitempty"`
|
|
Suffix string `json:"suffix,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
UniqueScope string `json:"unique_scope,omitempty"`
|
|
}
|
|
|
|
// trimRedundantSchemaLabels parses the schema JSON, drops field
|
|
// labels equal to TitleCase(key), and re-marshals. Returns the
|
|
// original bytes verbatim on any parse error so a malformed schema
|
|
// never blocks the bootstrap response — defensive only, since
|
|
// projectBootstrapCollection already gates on json.Valid() upstream.
|
|
//
|
|
// Field ordering is preserved by struct-based marshalling (Go's
|
|
// encoding/json emits fields in struct definition order); we
|
|
// deliberately avoid `map[string]any` which would shuffle order.
|
|
func trimRedundantSchemaLabels(raw []byte) json.RawMessage {
|
|
var s bootstrapSchema
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return raw
|
|
}
|
|
for i := range s.Fields {
|
|
if s.Fields[i].Label == titleCaseLabel(s.Fields[i].Key) {
|
|
s.Fields[i].Label = ""
|
|
}
|
|
}
|
|
out, err := json.Marshal(s)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return out
|
|
}
|
|
|
|
// titleCaseLabel converts a snake_case key into a Title Case label
|
|
// the same way the CLI does ("due_date" → "Due Date"). Duplicated
|
|
// from internal/mcp/dispatch_http_routes.go to avoid a server → mcp
|
|
// import (mcp already imports server). Keep the two in sync — both
|
|
// transformations must produce identical output for a given key.
|
|
func titleCaseLabel(key string) string {
|
|
parts := strings.Split(strings.ReplaceAll(key, "_", " "), " ")
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// BootstrapRole is the lightweight role projection delivered in the
|
|
// agent bootstrap response. Distinct from models.AgentRole: drops
|
|
// fields the /pad skill never reads (id, workspace_id, created_at,
|
|
// updated_at) and the unused tools column. Same drop-pattern as
|
|
// BootstrapCollection — agent addresses roles by slug; UUIDs and
|
|
// timestamps are dead weight at context-load time.
|
|
//
|
|
// Fields preserved are exactly what the /pad skill consumes:
|
|
// - slug — addressing (e.g. `--role <slug>` on item create/update)
|
|
// - name / description / icon — greeting + role-picker rendering
|
|
// - item_count — surface-area count in role-queue greetings
|
|
// - sort_order — preserves the workspace's authored role ordering
|
|
//
|
|
// PLAN-1410 / TASK-1423. Mirror of TASK-1412's BootstrapCollection
|
|
// projection.
|
|
type BootstrapRole struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
SortOrder int `json:"sort_order"`
|
|
ItemCount int `json:"item_count"`
|
|
}
|
|
|
|
// projectBootstrapRole converts a models.AgentRole into the slim
|
|
// bootstrap projection. Stateless — the role count recompute for
|
|
// restricted callers happens upstream and writes into c.ItemCount
|
|
// before this projection runs.
|
|
func projectBootstrapRole(r models.AgentRole) BootstrapRole {
|
|
return BootstrapRole{
|
|
Slug: r.Slug,
|
|
Name: r.Name,
|
|
Description: r.Description,
|
|
Icon: r.Icon,
|
|
SortOrder: r.SortOrder,
|
|
ItemCount: r.ItemCount,
|
|
}
|
|
}
|
|
|
|
// AgentBootstrapWorkspace is the minimal workspace projection (slug + name
|
|
// + id) the agent needs to address the workspace in subsequent calls.
|
|
// Description carries the free-text "what are you tracking?" captured at
|
|
// workspace creation (PLAN-1847 Phase 3 / TASK-1855) so the onboard playbook
|
|
// can start the interview warm instead of asking "what is this project?".
|
|
// Omitted when empty — additive to the bootstrap contract, so existing
|
|
// clients are unaffected.
|
|
type AgentBootstrapWorkspace struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// AgentBootstrapUser is the calling user's projection. Email is included
|
|
// so agents can sign generated commits or reference the human; ID is
|
|
// included so MCP servers can scope per-user data without an extra lookup.
|
|
type AgentBootstrapUser struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// AgentBootstrapConvention is a convention item with its body content so
|
|
// agents can read and follow it without a second round-trip. Only
|
|
// always-on, active conventions are returned — the curated must-follow
|
|
// set. trigger-specific conventions load on demand when their trigger
|
|
// fires.
|
|
//
|
|
// `slug` was dropped in PLAN-1410 / TASK-1413: the agent addresses
|
|
// items by `ref` (CONVE-N) — slug was dead weight.
|
|
type AgentBootstrapConvention struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Priority string `json:"priority,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Trigger string `json:"trigger,omitempty"`
|
|
}
|
|
|
|
// AgentBootstrapConventionMeta is the lightweight, body-less convention
|
|
// projection returned in the bootstrap's convention_index. It mirrors the
|
|
// AgentBootstrapPlaybookMeta pattern: metadata only, no content, so a
|
|
// workspace with dozens of triggered conventions costs the agent only a
|
|
// handful of bytes per entry (~40 bytes) at greeting time.
|
|
//
|
|
// The index exists so triggered (non-always) conventions are DISCOVERABLE.
|
|
// The always-on set ships its full bodies in AgentBootstrap.Conventions,
|
|
// but trigger-specific conventions (on-implement, on-task-complete,
|
|
// on-pr-create, …) previously never appeared even as titles — so agents
|
|
// never knew to fire the targeted `pad item list conventions --field
|
|
// trigger=<t>` query. The index lists ALL active conventions (always-on
|
|
// included, for a complete picture); the agent reads a trigger's count
|
|
// here and pulls the bodies on demand only when that trigger fires.
|
|
// TASK-2004.
|
|
type AgentBootstrapConventionMeta struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Trigger string `json:"trigger,omitempty"`
|
|
// Role is the slug of the agent role this convention is scoped to,
|
|
// when set. Most conventions are workspace-wide (no role) so this is
|
|
// omitted for them.
|
|
Role string `json:"role,omitempty"`
|
|
}
|
|
|
|
// AgentBootstrapPlaybookMeta is the lightweight playbook projection
|
|
// returned at bootstrap. Bodies (which can be 5-10KB each) are
|
|
// deliberately excluded; the agent loads a body on demand via
|
|
// `pad playbook show <slug>` only when a playbook is actually invoked.
|
|
// Keeping this metadata-only keeps the bootstrap payload small (~80
|
|
// bytes per entry) so a workspace with dozens of playbooks doesn't blow
|
|
// out the agent's context budget on a /pad greeting.
|
|
type AgentBootstrapPlaybookMeta struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
InvocationSlug string `json:"invocation_slug,omitempty"`
|
|
Trigger string `json:"trigger,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
// HasArguments is true when the playbook declares an arguments spec
|
|
// in its fields. The full spec is delivered on demand at invocation.
|
|
HasArguments bool `json:"has_arguments"`
|
|
// Summary is a short prose hint about what the playbook does, taken
|
|
// from the first non-heading non-empty paragraph of the body. Capped
|
|
// at ~240 chars so the bootstrap stays small.
|
|
Summary string `json:"summary,omitempty"`
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
// page consume the FULL set, unchanged. PLAN-1410 / TASK-1413.
|
|
//
|
|
// Because *DashboardResponse is embedded anonymously, encoding/json
|
|
// promotes ALL of its fields into the bootstrap wire shape — including
|
|
// `degraded` / `degraded_sections` (BUG-2014). Agents consuming the
|
|
// bootstrap blob therefore see partial-failure state on every surface
|
|
// (MCP `pad_meta.action: bootstrap`, the `pad://workspace/{ws}/bootstrap`
|
|
// resource, the `pad_set_workspace` embed). If this is ever converted to
|
|
// an explicit slim projection (like BootstrapCollection / BootstrapRole),
|
|
// those two fields MUST be carried across or the degraded signal silently
|
|
// disappears from the agent surfaces — TestBootstrapDashboardCarriesDegraded
|
|
// is the guard. BUG-2072.
|
|
type BootstrapDashboard struct {
|
|
*DashboardResponse
|
|
// AttentionOverflowCount is len(original attention) - cap, or
|
|
// omitted when nothing was trimmed. The agent reads this to decide
|
|
// whether to suggest pulling the full set via `pad project dashboard`.
|
|
AttentionOverflowCount int `json:"attention_overflow_count,omitempty"`
|
|
// 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"`
|
|
}
|
|
|
|
// 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 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
|
|
// the visibility filter. Used by the bootstrap path to gate
|
|
// convention/playbook queries on whether the caller can see those
|
|
// collections at all. The slice we're checking is already-filtered, so
|
|
// presence implies visibility.
|
|
func isCollectionSlugVisible(filtered []models.Collection, slug string) bool {
|
|
for _, c := range filtered {
|
|
if c.Slug == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// BuildAgentBootstrap assembles the bootstrap blob from store queries.
|
|
// This is the single canonical code path; the HTTP handler, the MCP
|
|
// resource handler, and the MCP `pad_set_workspace` embed all call this.
|
|
//
|
|
// r is the live request — used for the dashboard sub-build AND to
|
|
// resolve the calling principal's collection visibility / guest grant
|
|
// filter. Pass nil only when no request context is available (e.g. a
|
|
// future MCP in-process dispatcher synthesizing its own ACL context);
|
|
// in that case the bootstrap returns the full workspace view, which is
|
|
// safe ONLY for callers that have already verified full-member access
|
|
// out-of-band. Production HTTP/MCP paths MUST pass the live request.
|
|
func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *http.Request) (*AgentBootstrap, error) {
|
|
ws, err := s.store.GetWorkspaceByID(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &AgentBootstrap{
|
|
Workspace: AgentBootstrapWorkspace{
|
|
ID: ws.ID,
|
|
Slug: ws.Slug,
|
|
Name: ws.Name,
|
|
Description: ws.Description,
|
|
},
|
|
}
|
|
if user != nil {
|
|
out.User = AgentBootstrapUser{
|
|
ID: user.ID,
|
|
Name: user.Name,
|
|
Email: user.Email,
|
|
}
|
|
}
|
|
|
|
// Resolve visibility once so collections/conventions/playbooks/roles
|
|
// all project the same authorized view. nil visibleIDs means "no
|
|
// restriction" — a full workspace member (or a nil-r caller that
|
|
// has already verified access out-of-band). For guests with
|
|
// item-level grants, we also need ItemIDs filtering so a grant to
|
|
// one specific playbook doesn't leak the whole collection.
|
|
var visibleIDs []string
|
|
var grantedItemIDs []string
|
|
var fullCollIDs []string
|
|
if r != nil {
|
|
visibleIDs, err = s.visibleCollectionIDs(r, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fullCollIDs, grantedItemIDs, err = s.guestResourceFilter(r, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Collections — load and apply visibility filtering. We hold these
|
|
// in their full models.Collection shape through the role/count
|
|
// recompute below (which keys lookups by Collection.ID), then
|
|
// project to BootstrapCollection at the end of this section. The
|
|
// projection drops id/workspace_id/timestamps/settings and parses
|
|
// the schema string into a nested object — see BootstrapCollection
|
|
// godoc + PLAN-1410 / TASK-1412.
|
|
collections, err := s.store.ListCollections(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if visibleIDs != nil {
|
|
filtered := make([]models.Collection, 0, len(collections))
|
|
for _, c := range collections {
|
|
if isCollectionVisible(c.ID, visibleIDs) {
|
|
filtered = append(filtered, c)
|
|
}
|
|
}
|
|
collections = filtered
|
|
}
|
|
|
|
// Build the (collectionIDs, itemIDs) tuple the sub-queries should
|
|
// project through. When the caller has item-level grants, switch to
|
|
// the full-coll vs granted-item filter — same shape handleListItems
|
|
// uses. Without grants, fall back to collection-level visibility.
|
|
subCollIDs := visibleIDs
|
|
var subItemIDs []string
|
|
if len(grantedItemIDs) > 0 {
|
|
subCollIDs = fullCollIDs
|
|
subItemIDs = grantedItemIDs
|
|
}
|
|
|
|
// Conventions — only the always-on, active set, restricted by the
|
|
// caller's authorized view. A guest with a grant to one specific
|
|
// convention item gets only that item, not the whole always-on set.
|
|
conventionsCollVisible := visibleIDs == nil || isCollectionSlugVisible(collections, "conventions")
|
|
if conventionsCollVisible {
|
|
convs, cerr := s.collectAlwaysOnConventions(workspaceID, subCollIDs, subItemIDs)
|
|
if cerr != nil {
|
|
return nil, cerr
|
|
}
|
|
out.Conventions = convs
|
|
|
|
// convention_index: metadata-only catalog of EVERY active
|
|
// convention (all triggers), so the triggered set is discoverable
|
|
// without shipping bodies. See AgentBootstrapConventionMeta.
|
|
idx, ierr := s.collectConventionIndex(workspaceID, subCollIDs, subItemIDs)
|
|
if ierr != nil {
|
|
return nil, ierr
|
|
}
|
|
out.ConventionIndex = idx
|
|
} else {
|
|
out.Conventions = []AgentBootstrapConvention{}
|
|
out.ConventionIndex = []AgentBootstrapConventionMeta{}
|
|
}
|
|
|
|
// Agent roles — workspace-scoped, not collection-bound. Item counts
|
|
// MUST be recomputed below for restricted callers from the same
|
|
// visible item set used for collection counts.
|
|
roles, err := s.store.ListAgentRoles(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if roles == nil {
|
|
roles = []models.AgentRole{}
|
|
}
|
|
|
|
// For restricted callers, compute the visible item set ONCE and use
|
|
// it to (a) recompute role counts and (b) recompute collection
|
|
// item_count, both of which are otherwise computed across the whole
|
|
// workspace by their respective ListX queries and would leak
|
|
// hidden activity to a guest. Full members (visibleIDs == nil)
|
|
// skip the recompute — the store-side counts are already correct
|
|
// for them.
|
|
if visibleIDs != nil {
|
|
visibleItems, vierr := s.store.ListItems(workspaceID, models.ItemListParams{
|
|
CollectionIDs: subCollIDs,
|
|
ItemIDs: subItemIDs,
|
|
})
|
|
if vierr != nil {
|
|
return nil, vierr
|
|
}
|
|
roleCounts := make(map[string]int)
|
|
collItemCounts := make(map[string]int)
|
|
for _, item := range visibleItems {
|
|
if item.AgentRoleID != nil && *item.AgentRoleID != "" {
|
|
roleCounts[*item.AgentRoleID]++
|
|
}
|
|
collItemCounts[item.CollectionID]++
|
|
}
|
|
// Rewrite collection counts from the visible set.
|
|
// active_item_count needs each collection's done-rules to be
|
|
// accurate; recomputing it correctly is expensive (see
|
|
// dashboard's buildDoneContextMap) and the bootstrap consumers
|
|
// don't depend on it. Set it equal to item_count so restricted
|
|
// callers see a self-consistent number rather than a leaked
|
|
// full-workspace value. Full members keep the store-side
|
|
// active_item_count untouched.
|
|
//
|
|
// We mutate the local `collections` slice (models.Collection
|
|
// shape) here, before the bootstrap projection below — keyed
|
|
// by Collection.ID, which the projection drops.
|
|
for i := range collections {
|
|
c := &collections[i]
|
|
c.ItemCount = collItemCounts[c.ID]
|
|
c.ActiveItemCount = collItemCounts[c.ID]
|
|
}
|
|
// Overlay role counts. Same shape as collections above — mutate
|
|
// the local `roles` slice (models.AgentRole) here before the
|
|
// bootstrap projection below, keyed by AgentRole.ID which the
|
|
// projection drops.
|
|
for i := range roles {
|
|
roles[i].ItemCount = roleCounts[roles[i].ID]
|
|
}
|
|
}
|
|
|
|
// Project collections AND roles into their slim bootstrap shapes
|
|
// now that counts (above) have been recomputed for restricted
|
|
// callers. Both projections drop UUIDs / workspace IDs / timestamps;
|
|
// the agent addresses them by slug.
|
|
out.Collections = make([]BootstrapCollection, 0, len(collections))
|
|
for _, c := range collections {
|
|
out.Collections = append(out.Collections, projectBootstrapCollection(c))
|
|
}
|
|
out.Roles = make([]BootstrapRole, 0, len(roles))
|
|
for _, r := range roles {
|
|
out.Roles = append(out.Roles, projectBootstrapRole(r))
|
|
}
|
|
|
|
// Playbooks (metadata only) — restricted to the caller's authorized
|
|
// view. A guest granted one specific playbook item sees that one,
|
|
// not the whole collection.
|
|
playbooksCollVisible := visibleIDs == nil || isCollectionSlugVisible(collections, "playbooks")
|
|
if playbooksCollVisible {
|
|
playbooks, perr := s.collectPlaybookMetadata(workspaceID, subCollIDs, subItemIDs)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
out.Playbooks = playbooks
|
|
} else {
|
|
out.Playbooks = []AgentBootstrapPlaybookMeta{}
|
|
}
|
|
|
|
// Dashboard — recreate via the existing handler logic if a request
|
|
// context is available, then wrap in BootstrapDashboard so the
|
|
// 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 {
|
|
out.Dashboard = capBootstrapDashboard(dash)
|
|
}
|
|
}
|
|
|
|
// NeedsOnboarding: workspace-level signal (no per-caller visibility
|
|
// filtering — see Store.WorkspaceHasUserCreatedItems comment for
|
|
// the rationale). On a query error we fall back to false, which
|
|
// matches the safe default of "don't nag the agent." Worst case is
|
|
// a missed nudge; the opposite (nagging an already-onboarded
|
|
// workspace) is worse UX. PLAN-1496 / TASK-1504.
|
|
hasUserItems, hErr := s.store.WorkspaceHasUserCreatedItems(workspaceID)
|
|
if hErr == nil {
|
|
out.NeedsOnboarding = !hasUserItems
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// collectAlwaysOnConventions returns the active, always-on conventions for
|
|
// a workspace, projected into the bootstrap-friendly shape. Sorted by
|
|
// priority (must > should > nice-to-have) then by ref for stable order.
|
|
//
|
|
// collIDs / itemIDs scope the underlying ListItems call: nil collIDs
|
|
// means "no restriction" (full member); non-nil collIDs + non-nil
|
|
// itemIDs is the guest-with-item-grants shape from guestResourceFilter.
|
|
// A guest granted access to a single convention only sees that one.
|
|
func (s *Server) collectAlwaysOnConventions(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapConvention, error) {
|
|
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
|
CollectionSlug: "conventions",
|
|
CollectionIDs: collIDs,
|
|
ItemIDs: itemIDs,
|
|
Fields: map[string]string{
|
|
"status": "active",
|
|
"trigger": "always",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]AgentBootstrapConvention, 0, len(items))
|
|
for _, it := range items {
|
|
fields := map[string]any{}
|
|
_ = json.Unmarshal([]byte(it.Fields), &fields)
|
|
strField := func(k string) string {
|
|
if v, ok := fields[k].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
out = append(out, AgentBootstrapConvention{
|
|
Ref: it.Ref,
|
|
Title: it.Title,
|
|
Content: it.Content,
|
|
Priority: strField("priority"),
|
|
Scope: strField("scope"),
|
|
Trigger: strField("trigger"),
|
|
})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
pi := conventionPriorityRank(out[i].Priority)
|
|
pj := conventionPriorityRank(out[j].Priority)
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
return out[i].Ref < out[j].Ref
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// collectConventionIndex returns the metadata-only catalog of EVERY
|
|
// active convention in the workspace (all triggers, always-on included),
|
|
// projected into AgentBootstrapConventionMeta. Bodies are NOT loaded —
|
|
// the query uses NoContent so the potentially-large markdown column never
|
|
// leaves the DB. This backs AgentBootstrap.ConventionIndex; see that
|
|
// field + AgentBootstrapConventionMeta for why a body-less index is the
|
|
// point (triggered conventions must be discoverable without their bodies
|
|
// flooding the bootstrap). TASK-2004.
|
|
//
|
|
// collIDs / itemIDs scope the underlying ListItems call the same way
|
|
// collectAlwaysOnConventions does: nil collIDs means "no restriction"
|
|
// (full member); non-nil collIDs + non-nil itemIDs is the
|
|
// guest-with-item-grants shape. A guest granted one convention sees only
|
|
// that one in the index.
|
|
//
|
|
// Sorted by trigger, then ref for a stable, grouped order so an agent can
|
|
// eyeball "how many on-implement conventions exist" at a glance.
|
|
func (s *Server) collectConventionIndex(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapConventionMeta, error) {
|
|
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
|
CollectionSlug: "conventions",
|
|
CollectionIDs: collIDs,
|
|
ItemIDs: itemIDs,
|
|
NoContent: true,
|
|
Fields: map[string]string{
|
|
"status": "active",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]AgentBootstrapConventionMeta, 0, len(items))
|
|
for _, it := range items {
|
|
fields := map[string]any{}
|
|
_ = json.Unmarshal([]byte(it.Fields), &fields)
|
|
trigger := ""
|
|
if v, ok := fields["trigger"].(string); ok {
|
|
trigger = v
|
|
}
|
|
out = append(out, AgentBootstrapConventionMeta{
|
|
Ref: it.Ref,
|
|
Title: it.Title,
|
|
Trigger: trigger,
|
|
Role: it.AgentRoleSlug,
|
|
})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].Trigger != out[j].Trigger {
|
|
return out[i].Trigger < out[j].Trigger
|
|
}
|
|
return out[i].Ref < out[j].Ref
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// conventionPriorityRank ranks convention priority strings
|
|
// (must > should > nice-to-have). Lower rank = higher priority. Unknown
|
|
// values rank last so untyped data doesn't dominate the head of the list.
|
|
// Distinct from the task `priorityRank` in handlers_dashboard.go because
|
|
// conventions and tasks have disjoint priority vocabularies.
|
|
func conventionPriorityRank(p string) int {
|
|
switch p {
|
|
case "must":
|
|
return 0
|
|
case "should":
|
|
return 1
|
|
case "nice-to-have":
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
|
|
// collectPlaybookMetadata returns every playbook in the workspace projected
|
|
// down to the metadata shape. Bodies are NOT included.
|
|
//
|
|
// collIDs / itemIDs scope the underlying ListItems call: nil collIDs
|
|
// means "no restriction" (full member); non-nil collIDs + non-nil
|
|
// itemIDs is the guest-with-item-grants shape from guestResourceFilter.
|
|
// A guest granted access to a single playbook only sees that one.
|
|
func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapPlaybookMeta, error) {
|
|
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
|
CollectionSlug: "playbooks",
|
|
CollectionIDs: collIDs,
|
|
ItemIDs: itemIDs,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]AgentBootstrapPlaybookMeta, 0, len(items))
|
|
for _, it := range items {
|
|
fields := map[string]any{}
|
|
_ = json.Unmarshal([]byte(it.Fields), &fields)
|
|
strField := func(k string) string {
|
|
if v, ok := fields[k].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
args, hasArgs := fields["arguments"]
|
|
// Treat empty arrays / objects as "no arguments declared". A
|
|
// playbook with an empty arguments array is functionally
|
|
// identical to one that omits the field entirely.
|
|
if hasArgs {
|
|
switch v := args.(type) {
|
|
case []any:
|
|
hasArgs = len(v) > 0
|
|
case map[string]any:
|
|
hasArgs = len(v) > 0
|
|
case nil:
|
|
hasArgs = false
|
|
}
|
|
}
|
|
out = append(out, AgentBootstrapPlaybookMeta{
|
|
Ref: it.Ref,
|
|
Title: it.Title,
|
|
Slug: it.Slug,
|
|
InvocationSlug: strField("invocation_slug"),
|
|
Trigger: strField("trigger"),
|
|
Scope: strField("scope"),
|
|
Status: strField("status"),
|
|
HasArguments: hasArgs,
|
|
Summary: collections.PlaybookSummary(it.Content),
|
|
})
|
|
}
|
|
// Stable order: invocation_slug-bearing first (the user-facing,
|
|
// directly-callable set), then alphabetic by title within each group.
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
ai := out[i].InvocationSlug != ""
|
|
aj := out[j].InvocationSlug != ""
|
|
if ai != aj {
|
|
return ai
|
|
}
|
|
return out[i].Title < out[j].Title
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// capBootstrapDashboard wraps a DashboardResponse with the bootstrap's
|
|
// per-section caps. The underlying *DashboardResponse is shallow-copied
|
|
// before the slice headers are reslized so the caller's pointer (used by
|
|
// the dashboard endpoint elsewhere) sees its original full-length
|
|
// 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 all caps are
|
|
// untriggered, so the agent always sees a consistent shape.
|
|
func capBootstrapDashboard(d *DashboardResponse) *BootstrapDashboard {
|
|
copied := *d
|
|
out := &BootstrapDashboard{DashboardResponse: &copied}
|
|
if n := len(copied.Attention) - bootstrapAttentionCap; n > 0 {
|
|
copied.Attention = copied.Attention[:bootstrapAttentionCap]
|
|
out.AttentionOverflowCount = n
|
|
}
|
|
if n := len(copied.RecentActivity) - bootstrapRecentActivityCap; n > 0 {
|
|
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
|
|
}
|
|
|
|
// handleGetBootstrap is the HTTP handler for `GET
|
|
// /api/v1/workspaces/{ws}/agent/bootstrap`. It returns the consolidated
|
|
// AgentBootstrap blob in one round-trip so the /pad skill can replace its
|
|
// four context-loading CLI calls with one.
|
|
func (s *Server) handleGetBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
user := currentUser(r)
|
|
bootstrap, err := s.BuildAgentBootstrap(workspaceID, user, r)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, bootstrap)
|
|
}
|