mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
d18a5d8140
Two small additive trims to the BootstrapCollection projection
introduced in TASK-1412.
## 1. Omit redundant schema `label` when label == TitleCase(key)
A schema field's `label` is auto-fillable from its `key` (the CLI
and the MCP-side CreateCollection helper both apply
TitleCase(key) when label is empty — see titleCaseLabel in
internal/mcp/dispatch_http_routes.go). When the persisted label
matches that rule, it's redundant — the agent can reconstruct it
from the key. Examples on docapp:
- {"key":"status", "label":"Status"} ← redundant
- {"key":"due_date", "label":"Due Date"} ← redundant
- {"key":"trigger", "label":"When"} ← CUSTOM, preserved
Implementation: projectBootstrapCollection now passes the schema
bytes through trimRedundantSchemaLabels, which:
1. Unmarshals into a parallel bootstrapSchema/bootstrapFieldDef
struct purpose-built for the bootstrap shape.
2. Walks fields, clears any Label that equals TitleCase(Key).
3. Re-marshals — `omitempty` on bootstrapFieldDef.Label drops
the empty-string labels from the output.
Field ordering is preserved by struct-based marshalling.
## 2. Omitempty on BootstrapCollection.SortOrder
`sort_order` defaults to 0. Most collections never get an explicit
non-zero sort_order, so the field carried "sort_order":0 per entry
needlessly. Added ,omitempty to the struct tag.
## Drift detection
bootstrapFieldDef mirrors models.FieldDef field-for-field with two
deliberate differences: `Label` is omitempty, and `Default` is
json.RawMessage (so any default value round-trips verbatim
without re-parsing). The risk: if models.FieldDef gains a new
field, bootstrapFieldDef silently loses it from the bootstrap
schema response.
TestBootstrapFieldDefMirrorsModelsFieldDef catches this via
reflection — compares NumField + per-field JSON tags + has an
explicit allow-list for the Label tag delta. A new field added to
models.FieldDef without mirroring here fails the test with a
field-name-pointed error message.
## Test coverage
- TestBootstrapFieldDefMirrorsModelsFieldDef — drift detector.
- TestTrimRedundantSchemaLabels (4 subtests):
* drops-redundant-labels
* preserves-custom-labels (key="trigger", label="When" stays)
* multi-word-keys-titlecase-correctly (due_date → Due Date)
* malformed-schema-returns-raw (defensive: never block on parse error)
- Existing TestBootstrapSizeBudget shows fixture collections
section drop: 3,979 → 3,532 bytes (-11.2%).
## Measurements
Fixture (TestBootstrapSizeBudget): 7,823 → 7,376 bytes (-447 b / -5.7%).
Collections section alone: 3,979 → 3,532 bytes (-447 b / -11.2%) —
all of the win is concentrated in schema bytes via the label trim.
Live docapp expected savings: 9,384 → ~8,400 bytes (~10% drop on
collections), totaling roughly 600-900 b additional reduction on
the bootstrap response.
## Out of scope
- ToolSurfaceVersion 0.3 → 0.4 bump — TASK-1418 (the FINAL PR in
PLAN-1410). This is the last shape PR; TASK-1418 is now unblocked.
Parent: PLAN-1410. With this merged, PLAN-1410's bootstrap-shape
work is complete; only the contractual v0.4 announcement remains.
783 lines
30 KiB
Go
783 lines
30 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"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"`
|
|
Roles []BootstrapRole `json:"roles"`
|
|
Playbooks []AgentBootstrapPlaybookMeta `json:"playbooks"`
|
|
Dashboard *BootstrapDashboard `json:"dashboard,omitempty"`
|
|
}
|
|
|
|
// 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.
|
|
type AgentBootstrapWorkspace struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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.
|
|
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,
|
|
},
|
|
}
|
|
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
|
|
} else {
|
|
out.Conventions = []AgentBootstrapConvention{}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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: 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
|
|
}
|
|
|
|
// playbookSummary extracts a short prose hint from a playbook body. Picks
|
|
// the first non-heading non-empty paragraph and caps at ~240 chars so the
|
|
// bootstrap stays compact.
|
|
func playbookSummary(body string) string {
|
|
const maxLen = 240
|
|
const ellipsis = "…"
|
|
for _, line := range splitLines(body) {
|
|
trimmed := trimLeadingSpaces(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
// Skip markdown headings — they're labels, not summaries.
|
|
if len(trimmed) > 0 && trimmed[0] == '#' {
|
|
continue
|
|
}
|
|
if len(trimmed) > maxLen {
|
|
return trimmed[:maxLen-len(ellipsis)] + ellipsis
|
|
}
|
|
return trimmed
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// splitLines is a small dependency-free helper. We avoid bufio.Scanner
|
|
// here because the typical body is small (under 50KB) and allocating a
|
|
// scanner per playbook is wasteful at this scale.
|
|
func splitLines(s string) []string {
|
|
out := []string{}
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
out = append(out, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
out = append(out, s[start:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trimLeadingSpaces(s string) string {
|
|
i := 0
|
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
|
i++
|
|
}
|
|
return s[i:]
|
|
}
|
|
|
|
// 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)
|
|
}
|