diff --git a/docs/api.md b/docs/api.md index 2a9bc64a5..2e8549dfc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -70,6 +70,7 @@ REST API at `/api/v1/`. Key endpoints: - `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent - `GET /workspaces/{ws}/items/{slug}/progress` — child item completion progress - `GET/POST /workspaces/{ws}/items/{slug}/links` — item relationships (blocks/blocked-by, parent/child) +- `GET /workspaces/{ws}/items/{slug}/context?intent=&role=&budget=` (PLAN-3598) — one deterministic brief for working an item: `item` (fields, body, seq), `parents` (the visible ancestor chain, nearest first, each with `acceptance_criteria` parsed by `internal/acceptance` and the nearest one's body), `conventions` (active ones whose trigger is `always` or the intent's, with bodies, sorted must → should → nice-to-have then ref), `playbooks` (metadata only), `deps` (`blocks` / `blocked_by` with statuses), `decisions` (each with `current`), `links` (other link types, ref/title/status, no bodies), `omitted`, and `conflicts`. Every part carries its ref and seq. **Intent** resolves against the conventions collection's declared trigger options: `on-` if declared, else ``; neither is 400 `unknown_intent` with `details.valid_intents`. No intent means `always` only. **Role** must be a role slug in the workspace (400 `unknown_role`); with one, a convention scoped to another role (by its agent-role column, the one bootstrap labels with) is left out, and without one every role's are included, as bootstrap does. **Budget** (characters of serialized parts, default 40000, capped at 400000) admits whole parts in a fixed order: parents, conventions, playbooks, deps, decisions, links. The first part that does not fit and every part after it are listed in `omitted` (`part`, `ref`, `seq`, `size`, `reason: "budget"`), so a later small part never slips in after a refused one. The item always ships, even past the budget. **Visibility:** the item uses the single-item read check (an unreadable item is the same 404 as a missing one); every ancestor, dependency and linked item uses `itemVisibleFor` plus the app token's collection ceiling and live rows only; conventions and playbooks use bootstrap's visibility sets. A part the caller cannot see is absent and NOT counted in `omitted`. **`conflicts`** is structural only (no semantic comparison of rules): `open_blocker`, `open_under_closed_parent`, `stale_decision`, and `role_and_global_convention` (a role-scoped and a global convention matching the same trigger under the same title) - `GET /search?q=query&workspace=slug` — full-text search. A `collection` filter resolves like a collection in the item routes (BUG-2659): exact slug first, then the singular/alias fallback, and an archived collection still claims its name. It resolves once per workspace in scope, so `collection=task` means `task` where one exists and `tasks` where it does not. `GET /server/capabilities` advertises it as `search_collection_resolution`; the CLI sends the name as typed only to a server that does, and expands the shorthand itself otherwise. `include_archived=true` (TASK-2864) adds soft-deleted items on every path (full text, ref and number lookups, total, facets), each carrying `deleted_at`. The permission filter is unchanged, and an individual item grant names live items only, so a guest sees an archived item only through a collection grant. The web search box's `is:archived` token sends it; not on the CLI or MCP - `GET /api/v1/events?workspace=slug` — SSE real-time events (workspace-scoped) - `GET /api/v1/events/stream` — SSE watch/push notifications (USER-scoped, spans every workspace the caller belongs to; backs `pad watch --stream`). With `?access=true` (TASK-3272) the stream also carries `workspace_access_changed` addressed to the caller — `{workspace_id, change}` with change `gained` / `lost` / `deleted` / `restored` / `purged` — published after every door that changes whether an EXISTING account reaches a workspace (signup-time doors publish nothing: the new account holds no stream yet). Opt-in because an installed `pad watch` prints any kind it does not know as an item line into an agent session. It is a refetch hint: correctness lives on the read-filtered `GET /me/workspace-tabs`, and a duplicate or missed event under concurrent grant writes is benign. Such a stream counts against `PAD_SSE_MAX_PER_USER` like any other. A push event carries `push_id` (TASK-3585, omitted on every other kind): the same id on every publish of one keyed push, so a consumer drops one it has seen (`pad watch --stream --for-session` keeps ~256 ids for 10 minutes); a monitor that predates it is at-least-once diff --git a/internal/server/handlers_item_context.go b/internal/server/handlers_item_context.go new file mode 100644 index 000000000..6077378de --- /dev/null +++ b/internal/server/handlers_item_context.go @@ -0,0 +1,625 @@ +package server + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/acceptance" + "github.com/PerpetualSoftware/pad/internal/artifact" + "github.com/PerpetualSoftware/pad/internal/collections" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// GET /workspaces/{ws}/items/{ref}/context?intent=&role=&budget= (PLAN-3598 +// slice (b)): one deterministic brief of what an agent needs before working +// an item, replacing the bootstrap + item + parent + conventions + deps +// sequence each agent used to stitch by hand. No LLM is involved: selection +// is a fixed function of the workspace's state and the parameters. +// +// Every part goes through the per-row read check its own endpoint applies +// (requireItemVisible for the item; itemVisibleFor, the BUG-3366 check, for +// every ancestor, dependency and linked item; the bootstrap visibility sets +// for conventions). A part the caller cannot see is left out and NOT counted +// in `omitted`, because a count would make this an existence oracle. +// +// Budget: parts are included whole, in a fixed order, and the first part that +// does not fit ends the brief; it and everything after it are listed in +// `omitted` with their size. Nothing is cut mid-body, and a must-convention is +// never dropped while a should-convention after it stays. The item itself is +// always included, even past the budget. + +const ( + itemContextDefaultBudget = 40000 + itemContextMaxBudget = 400000 + itemContextMaxAncestors = 20 +) + +type itemContextItem struct { + Ref string `json:"ref"` + Title string `json:"title"` + Slug string `json:"slug"` + Collection string `json:"collection"` + Seq int64 `json:"seq"` + Role string `json:"role,omitempty"` + Fields json.RawMessage `json:"fields"` + Content string `json:"content"` +} + +type itemContextParent struct { + Ref string `json:"ref"` + Title string `json:"title"` + Collection string `json:"collection"` + Seq int64 `json:"seq"` + Status string `json:"status,omitempty"` + AcceptanceCriteria *acceptance.Section `json:"acceptance_criteria"` + // Content is shipped for the nearest ancestor only; the rest are named. + Content string `json:"content,omitempty"` +} + +type itemContextConvention struct { + Ref string `json:"ref"` + Title string `json:"title"` + Seq int64 `json:"seq"` + Trigger string `json:"trigger"` + Priority string `json:"priority,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content"` +} + +type itemContextPlaybook struct { + Ref string `json:"ref"` + Title string `json:"title"` + Trigger string `json:"trigger"` + InvocationSlug string `json:"invocation_slug,omitempty"` +} + +type itemContextLinked struct { + Ref string `json:"ref"` + Title string `json:"title"` + LinkType string `json:"link_type"` + Direction string `json:"direction"` // "outgoing" (this item is the source) or "incoming" + Status string `json:"status,omitempty"` +} + +type itemContextDeps struct { + Blocks []itemContextLinked `json:"blocks"` + BlockedBy []itemContextLinked `json:"blocked_by"` +} + +type itemContextOmitted struct { + Part string `json:"part"` + Ref string `json:"ref,omitempty"` + Seq int64 `json:"seq,omitempty"` + Size int `json:"size"` + Reason string `json:"reason"` +} + +type itemContextConflict struct { + Kind string `json:"kind"` + Refs []string `json:"refs"` + Detail string `json:"detail"` +} + +type itemContextResponse struct { + Intent string `json:"intent"` + Triggers []string `json:"triggers"` + Role string `json:"role,omitempty"` + Budget int `json:"budget"` + Used int `json:"used"` + Item itemContextItem `json:"item"` + Parents []itemContextParent `json:"parents"` + Conventions []itemContextConvention `json:"conventions"` + Playbooks []itemContextPlaybook `json:"playbooks"` + Deps itemContextDeps `json:"deps"` + Decisions []models.ItemDecision `json:"decisions"` + Links []itemContextLinked `json:"links"` + Omitted []itemContextOmitted `json:"omitted"` + Conflicts []itemContextConflict `json:"conflicts"` +} + +func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + item, err := s.store.ResolveItemIncludeDeleted(workspaceID, chi.URLParam(r, "itemSlug")) + if err != nil { + writeInternalError(w, err) + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if !s.requireItemVisible(w, r, workspaceID, item) { + return + } + + q := r.URL.Query() + budget := itemContextDefaultBudget + if v := q.Get("budget"); v != "" { + n, perr := strconv.Atoi(v) + if perr != nil || n <= 0 { + writeError(w, http.StatusBadRequest, "bad_request", "budget must be a positive integer (characters)") + return + } + if n > itemContextMaxBudget { + n = itemContextMaxBudget + } + budget = n + } + + traited, err := s.store.ListTraitedCollections(workspaceID) + if err != nil { + writeInternalError(w, err) + return + } + convColl := collections.FindByArtifactKind(traited, string(artifact.KindConvention)) + + intent := strings.TrimSpace(q.Get("intent")) + triggers := []string{"always"} + if intent != "" { + trig, valid, terr := s.resolveContextIntent(convColl, intent) + if terr != nil { + writeInternalError(w, terr) + return + } + if trig == "" { + writeError2(w, http.StatusBadRequest, "unknown_intent", + "intent "+strconv.Quote(intent)+" matches no trigger declared on this workspace's conventions", + map[string]interface{}{"valid_intents": valid}) + return + } + if trig != "always" { + triggers = append(triggers, trig) + } + } + + role := strings.TrimSpace(q.Get("role")) + if role != "" { + rl, rerr := s.store.GetAgentRole(workspaceID, role) + if rerr != nil || rl == nil || rl.Slug != role { + writeError(w, http.StatusBadRequest, "unknown_role", "role "+strconv.Quote(role)+" is not a role slug in this workspace") + return + } + } + + visible := s.contextRelatedVisible(r, workspaceID) + sc := newContextSchemaCache(s) + resp := itemContextResponse{ + Intent: intent, Triggers: triggers, Role: role, Budget: budget, + Parents: []itemContextParent{}, Conventions: []itemContextConvention{}, + Playbooks: []itemContextPlaybook{}, Decisions: []models.ItemDecision{}, + Links: []itemContextLinked{}, Omitted: []itemContextOmitted{}, Conflicts: []itemContextConflict{}, + Deps: itemContextDeps{Blocks: []itemContextLinked{}, BlockedBy: []itemContextLinked{}}, + } + fields := json.RawMessage(item.Fields) + if !json.Valid(fields) { + fields = json.RawMessage("{}") + } + resp.Item = itemContextItem{ + Ref: item.Ref, Title: item.Title, Slug: item.Slug, Collection: item.CollectionSlug, + Seq: item.Seq, Role: item.AgentRoleSlug, Fields: fields, Content: item.Content, + } + b := &contextBudget{limit: budget, resp: &resp} + b.used = partSize(resp.Item) + + // Ancestors, nearest first, stopping at the first one the caller cannot + // see: an invisible link in the chain must not expose the ones above it. + ancestors, err := s.contextAncestors(item, visible) + if err != nil { + writeInternalError(w, err) + return + } + itemOpen := !sc.terminal(item) + for i, a := range ancestors { + p := itemContextParent{Ref: a.Ref, Title: a.Title, Collection: a.CollectionSlug, Seq: a.Seq, Status: contextFieldString(a.Fields, "status")} + if ac := acceptance.Parse(a.Content); ac.Found { + p.AcceptanceCriteria = &ac + } + if i == 0 { + p.Content = a.Content + if itemOpen && sc.terminal(&a) { + resp.Conflicts = append(resp.Conflicts, itemContextConflict{Kind: "open_under_closed_parent", Refs: []string{item.Ref, a.Ref}, + Detail: item.Ref + " is open but its parent " + a.Ref + " is closed"}) + } + } + if !b.take("parent", a.Ref, a.Seq, p) { + break + } + resp.Parents = append(resp.Parents, p) + } + + // Conventions whose trigger matches, deduplicated, must → should → nice, + // then by ref; role-scoped ones only for the named role when one is given. + convs, err := s.contextConventions(r, workspaceID, convColl, triggers, role) + if err != nil { + writeInternalError(w, err) + return + } + for _, c := range convs { + if !b.take("convention", c.Ref, c.Seq, c) { + continue + } + resp.Conventions = append(resp.Conventions, c) + } + resp.Conflicts = append(resp.Conflicts, conventionTitleConflicts(resp.Conventions)...) + + // Playbooks are metadata only, like bootstrap's. + pbs, err := s.contextPlaybooks(r, workspaceID, traited, triggers) + if err != nil { + writeInternalError(w, err) + return + } + for _, p := range pbs { + if !b.take("playbook", p.Ref, 0, p) { + continue + } + resp.Playbooks = append(resp.Playbooks, p) + } + + // Dependencies, then other links; each through the per-row check. + links, err := s.store.GetItemLinks(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + var deps, others []itemContextLinked + var depKind []string + for _, l := range links { + outgoing := l.SourceID == item.ID + otherID, ref, title, status := l.SourceID, l.SourceRef, l.SourceTitle, l.SourceStatus + if outgoing { + otherID, ref, title, status = l.TargetID, l.TargetRef, l.TargetTitle, l.TargetStatus + } + if l.LinkType == models.ItemLinkTypeParent || !visible(otherID) { + continue + } + e := itemContextLinked{Ref: ref, Title: title, LinkType: l.LinkType, Status: status, Direction: "incoming"} + if outgoing { + e.Direction = "outgoing" + } + if l.LinkType == models.ItemLinkTypeBlocks { + deps = append(deps, e) + kind := "blocked_by" + if outgoing { + kind = "blocks" + } + depKind = append(depKind, kind) + if !outgoing && itemOpen { + if other, gerr := s.store.GetItem(otherID); gerr == nil && other != nil && !sc.terminal(other) { + resp.Conflicts = append(resp.Conflicts, itemContextConflict{Kind: "open_blocker", Refs: []string{item.Ref, ref}, + Detail: item.Ref + " is blocked by " + ref + ", which is still open"}) + } + } + continue + } + others = append(others, e) + } + for i, d := range deps { + if !b.take("dependency", d.Ref, 0, d) { + continue + } + if depKind[i] == "blocks" { + resp.Deps.Blocks = append(resp.Deps.Blocks, d) + } else { + resp.Deps.BlockedBy = append(resp.Deps.BlockedBy, d) + } + } + + decs, err := s.decisionRunner().Decisions(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + for _, d := range decs { + if !d.Current { + resp.Conflicts = append(resp.Conflicts, itemContextConflict{Kind: "stale_decision", Refs: []string{item.Ref}, + Detail: "decision " + d.QuestionKey + " was evaluated against an earlier state of " + item.Ref}) + } + if !b.take("decision", "", d.ItemSeq, d) { + continue + } + resp.Decisions = append(resp.Decisions, d) + } + + for _, l := range others { + if !b.take("link", l.Ref, 0, l) { + continue + } + resp.Links = append(resp.Links, l) + } + + resp.Used = b.used + writeJSON(w, http.StatusOK, resp) +} + +// contextRelatedVisible is the per-row check for every item the brief names +// besides the item itself: itemVisibleFor (the BUG-3366 check), plus the app +// token's collection ceiling that requireItemVisible applies and +// itemVisibleFor does not, and live rows only. It fails closed. +func (s *Server) contextRelatedVisible(r *http.Request, workspaceID string) func(id string) bool { + visible := s.itemVisibleFor(r, workspaceID) + memo := map[string]bool{} + return func(id string) bool { + if v, ok := memo[id]; ok { + return v + } + ok := false + if visible(id) { + if it, err := s.store.GetItem(id); err == nil && it != nil && it.DeletedAt == nil && appCeilingAllows(r, it.CollectionID) { + ok = true + } + } + memo[id] = ok + return ok + } +} + +// contextBudget admits parts in order until the first one that does not fit; +// from then on every part is omitted, so order is never inverted by a +// smaller part slipping in after a larger one was refused. +type contextBudget struct { + limit, used int + full bool + resp *itemContextResponse +} + +func (b *contextBudget) take(part, ref string, seq int64, v any) bool { + n := partSize(v) + if !b.full && b.used+n <= b.limit { + b.used += n + return true + } + b.full = true + b.resp.Omitted = append(b.resp.Omitted, itemContextOmitted{Part: part, Ref: ref, Seq: seq, Size: n, Reason: "budget"}) + return false +} + +func partSize(v any) int { + raw, err := json.Marshal(v) + if err != nil { + return 0 + } + return len(raw) +} + +// resolveContextIntent maps an intent onto a trigger the workspace declares on +// its conventions: "on-" if declared, else "" itself. When +// neither is declared it returns "" and the valid intents. +func (s *Server) resolveContextIntent(conv *collections.TraitedCollection, intent string) (string, []string, error) { + var options []string + if conv != nil { + c, err := s.store.GetCollection(conv.ID) + if err != nil { + return "", nil, err + } + if c != nil { + var schema models.CollectionSchema + if json.Unmarshal([]byte(c.Schema), &schema) == nil { + for _, f := range schema.Fields { + if f.Key == "trigger" { + options = f.Options + } + } + } + } + } + declared := map[string]bool{} + valid := []string{} + for _, o := range options { + declared[o] = true + if o != "always" { + valid = append(valid, strings.TrimPrefix(o, "on-")) + } + } + sort.Strings(valid) + if declared["on-"+intent] { + return "on-" + intent, valid, nil + } + if declared[intent] { + return intent, valid, nil + } + return "", valid, nil +} + +func (s *Server) contextAncestors(item *models.Item, visible func(string) bool) ([]models.Item, error) { + var out []models.Item + seen := map[string]bool{item.ID: true} + cur := item.ID + for len(out) < itemContextMaxAncestors { + link, err := s.store.GetParentForItem(cur) + if err != nil { + return nil, err + } + if link == nil || seen[link.TargetID] || !visible(link.TargetID) { + break + } + p, err := s.store.GetItem(link.TargetID) + if err != nil { + return nil, err + } + if p == nil { + break + } + seen[p.ID] = true + out = append(out, *p) + cur = p.ID + } + return out, nil +} + +// contextVisibility is the bootstrap's list-level restriction for the caller +// (BuildAgentBootstrap): nil sets mean an unrestricted member. +func (s *Server) contextVisibility(r *http.Request, workspaceID string) (visibleIDs, collIDs, itemIDs []string, err error) { + visibleIDs, err = s.visibleCollectionIDs(r, workspaceID) + if err != nil { + return nil, nil, nil, err + } + fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, workspaceID) + if err != nil { + return nil, nil, nil, err + } + collIDs = visibleIDs + if len(grantedItemIDs) > 0 { + collIDs, itemIDs = fullCollIDs, grantedItemIDs + } + return visibleIDs, collIDs, itemIDs, nil +} + +func (s *Server) contextListActive(r *http.Request, workspaceID, collID string, needsContent bool) ([]models.Item, error) { + visibleIDs, collIDs, itemIDs, err := s.contextVisibility(r, workspaceID) + if err != nil { + return nil, err + } + if visibleIDs != nil && !isCollectionVisible(collID, visibleIDs) { + return nil, nil + } + return s.store.ListItems(workspaceID, models.ItemListParams{ + ScopeCollectionID: collID, + CollectionIDs: collIDs, + ItemIDs: itemIDs, + Fields: map[string]string{"status": "active"}, + NoContent: !needsContent, + }) +} + +func (s *Server) contextConventions(r *http.Request, workspaceID string, conv *collections.TraitedCollection, triggers []string, role string) ([]itemContextConvention, error) { + if conv == nil { + return nil, nil + } + items, err := s.contextListActive(r, workspaceID, conv.ID, true) + if err != nil { + return nil, err + } + want := map[string]bool{} + for _, t := range triggers { + want[t] = true + } + var out []itemContextConvention + seen := map[string]bool{} + for _, it := range items { + trig := contextFieldString(it.Fields, "trigger") + if !want[trig] || seen[it.ID] { + continue + } + if role != "" && it.AgentRoleSlug != "" && it.AgentRoleSlug != role { + continue + } + seen[it.ID] = true + out = append(out, itemContextConvention{ + Ref: it.Ref, Title: it.Title, Seq: it.Seq, Trigger: trig, + Priority: contextFieldString(it.Fields, "priority"), Role: it.AgentRoleSlug, Content: it.Content, + }) + } + sort.SliceStable(out, func(i, j int) bool { + pi, pj := conventionPriorityRank(out[i].Priority), conventionPriorityRank(out[j].Priority) + if pi != pj { + return pi < pj + } + return out[i].Ref < out[j].Ref + }) + return out, nil +} + +func (s *Server) contextPlaybooks(r *http.Request, workspaceID string, traited []collections.TraitedCollection, triggers []string) ([]itemContextPlaybook, error) { + pb := collections.FindByArtifactKind(traited, string(artifact.KindPlaybook)) + if pb == nil { + return nil, nil + } + items, err := s.contextListActive(r, workspaceID, pb.ID, false) + if err != nil { + return nil, err + } + want := map[string]bool{} + for _, t := range triggers { + want[t] = true + } + var out []itemContextPlaybook + for _, it := range items { + trig := contextFieldString(it.Fields, "trigger") + if !want[trig] { + continue + } + out = append(out, itemContextPlaybook{Ref: it.Ref, Title: it.Title, Trigger: trig, InvocationSlug: contextFieldString(it.Fields, "invocation_slug")}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Ref < out[j].Ref }) + return out, nil +} + +// conventionTitleConflicts reports a role-scoped and a global convention that +// matched the same trigger under the same title: the deterministic shape of +// "two rules that may disagree". Semantic disagreement is not detected. +func conventionTitleConflicts(convs []itemContextConvention) []itemContextConflict { + type key struct{ trigger, title string } + global := map[key]string{} + for _, c := range convs { + if c.Role == "" { + global[key{c.Trigger, strings.ToLower(strings.TrimSpace(c.Title))}] = c.Ref + } + } + var out []itemContextConflict + for _, c := range convs { + if c.Role == "" { + continue + } + if g, ok := global[key{c.Trigger, strings.ToLower(strings.TrimSpace(c.Title))}]; ok { + out = append(out, itemContextConflict{Kind: "role_and_global_convention", Refs: []string{g, c.Ref}, + Detail: c.Ref + " (role " + c.Role + ") and " + g + " (global) share the title " + strconv.Quote(c.Title) + " on trigger " + c.Trigger}) + } + } + return out +} + +func contextFieldString(fieldsJSON, key string) string { + var m map[string]any + if json.Unmarshal([]byte(fieldsJSON), &m) != nil { + return "" + } + v, _ := m[key].(string) + return v +} + +// contextSchemaCache answers "is this item closed?" per collection, with the +// done-field-aware check (models.IsTerminalItem). +type contextSchemaCache struct { + s *Server + by map[string]*contextSchema +} + +type contextSchema struct { + schema models.CollectionSchema + settings models.CollectionSettings + ok bool +} + +func newContextSchemaCache(s *Server) *contextSchemaCache { + return &contextSchemaCache{s: s, by: map[string]*contextSchema{}} +} + +func (c *contextSchemaCache) terminal(it *models.Item) bool { + cs, ok := c.by[it.CollectionID] + if !ok { + cs = &contextSchema{} + if coll, err := c.s.store.GetCollection(it.CollectionID); err == nil && coll != nil { + cs.ok = json.Unmarshal([]byte(coll.Schema), &cs.schema) == nil + if coll.Settings != "" { + _ = json.Unmarshal([]byte(coll.Settings), &cs.settings) + } + } + c.by[it.CollectionID] = cs + } + if !cs.ok { + return false + } + var m map[string]any + if json.Unmarshal([]byte(it.Fields), &m) != nil { + return false + } + return models.IsTerminalItem(m, cs.schema, cs.settings) +} diff --git a/internal/server/plan3598_item_context_test.go b/internal/server/plan3598_item_context_test.go new file mode 100644 index 000000000..b652612bd --- /dev/null +++ b/internal/server/plan3598_item_context_test.go @@ -0,0 +1,278 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// PLAN-3598 slice (b): GET /items/{ref}/context. + +type ctxFixture struct { + *accessFixture + plan, task, blocker, related models.Item + conv map[string]models.Item +} + +func newCtxFixture(t *testing.T, d store.DriverType) *ctxFixture { + t.Helper() + f := &ctxFixture{accessFixture: newAccessFixture(t, d), conv: map[string]models.Item{}} + f.plan = f.itemIn("plans", map[string]any{"title": "Ctx plan", "content": "## Acceptance criteria\n\n- AC-1: the brief names its parent\n- AC-2: \n"}) + f.task = f.itemIn("tasks", map[string]any{"title": "Ctx task", "content": "task body", "fields": `{"status":"open","parent":"` + f.plan.Ref + `"}`}) + f.blocker = f.itemIn("tasks", map[string]any{"title": "Ctx blocker", "fields": `{"status":"open"}`}) + f.related = f.itemIn("tasks", map[string]any{"title": "Ctx related", "fields": `{"status":"open"}`}) + if _, err := f.srv.store.CreateItemLink(f.wsID, models.ItemLinkCreate{TargetID: f.task.ID, LinkType: models.ItemLinkTypeBlocks}, f.blocker.ID); err != nil { + t.Fatalf("blocks link: %v", err) + } + if _, err := f.srv.store.CreateItemLink(f.wsID, models.ItemLinkCreate{TargetID: f.related.ID, LinkType: models.ItemLinkTypeRelated}, f.task.ID); err != nil { + t.Fatalf("related link: %v", err) + } + + impl, err := f.srv.store.CreateAgentRole(f.wsID, models.AgentRoleCreate{Name: "Implementer", Slug: "implementer"}) + if err != nil { + t.Fatal(err) + } + rev, err := f.srv.store.CreateAgentRole(f.wsID, models.AgentRoleCreate{Name: "Reviewer", Slug: "reviewer"}) + if err != nil { + t.Fatal(err) + } + conv, err := f.srv.store.GetCollectionBySlug(f.wsID, "conventions") + if err != nil || conv == nil { + t.Fatalf("conventions collection: %v", err) + } + mk := func(key, title, trigger, priority string, role *string) { + fields, _ := json.Marshal(map[string]string{"status": "active", "trigger": trigger, "priority": priority, "scope": "all"}) + it, err := f.srv.store.CreateItem(f.wsID, conv.ID, models.ItemCreate{Title: title, Content: "body of " + title, Fields: string(fields), AgentRoleID: role}) + if err != nil { + t.Fatalf("convention %s: %v", key, err) + } + f.conv[key] = *it + } + mk("always-must", "Ctx always rule", "always", "must", nil) + mk("impl-should", "Ctx implement rule", "on-implement", "should", nil) + mk("impl-must", "Ctx implement must rule", "on-implement", "must", nil) + mk("commit", "Ctx commit rule", "on-commit", "must", nil) + mk("impl-reviewer", "Ctx reviewer rule", "on-implement", "must", &rev.ID) + mk("impl-implementer", "Ctx implement rule", "on-implement", "should", &impl.ID) + return f +} + +func (f *ctxFixture) context(tok, query string) (*itemContextResponse, int, string) { + f.t.Helper() + rr := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/"+f.task.Ref+"/context"+query, tok, nil) + if rr.Code != http.StatusOK { + return nil, rr.Code, rr.Body.String() + } + var resp itemContextResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + f.t.Fatalf("decode: %v: %s", err, rr.Body.String()) + } + return &resp, rr.Code, rr.Body.String() +} + +func convRefs(cs []itemContextConvention) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = c.Ref + } + return out +} + +func ctxIndex(xs []string, x string) int { + for i, v := range xs { + if v == x { + return i + } + } + return -1 +} + +func TestPLAN3598_ItemContextBrief(t *testing.T) { + forEachDialect(t, func(t *testing.T, d store.DriverType) { + f := newCtxFixture(t, d) + resp, code, body := f.context(f.ownerTok, "?intent=implement&role=implementer") + if resp == nil { + t.Fatalf("status %d: %s", code, body) + } + if resp.Item.Ref != f.task.Ref || resp.Item.Seq == 0 || resp.Item.Content != "task body" { + t.Errorf("item = %+v", resp.Item) + } + if strings.Join(resp.Triggers, ",") != "always,on-implement" { + t.Errorf("triggers = %v", resp.Triggers) + } + + // The parent, with its acceptance criteria parsed, placeholders apart. + if len(resp.Parents) != 1 || resp.Parents[0].Ref != f.plan.Ref { + t.Fatalf("parents = %+v", resp.Parents) + } + ac := resp.Parents[0].AcceptanceCriteria + if ac == nil || len(ac.Criteria) != 1 || ac.Criteria[0].ID != "AC-1" || len(ac.Placeholders) != 1 { + t.Errorf("acceptance criteria = %+v", ac) + } + + // Conventions: always + on-implement, global + this role's; not + // on-commit, not another role's; must before should. + refs := convRefs(resp.Conventions) + for _, k := range []string{"always-must", "impl-should", "impl-must", "impl-implementer"} { + if ctxIndex(refs, f.conv[k].Ref) < 0 { + t.Errorf("missing convention %s (%s) in %v", k, f.conv[k].Ref, refs) + } + } + for _, k := range []string{"commit", "impl-reviewer"} { + if ctxIndex(refs, f.conv[k].Ref) >= 0 { + t.Errorf("convention %s should not be in the brief: %v", k, refs) + } + } + if ctxIndex(refs, f.conv["impl-must"].Ref) > ctxIndex(refs, f.conv["impl-should"].Ref) { + t.Errorf("a must convention sorts after a should one: %v", refs) + } + for _, c := range resp.Conventions { + if c.Content == "" || c.Seq == 0 { + t.Errorf("convention %s ships no body or seq", c.Ref) + } + } + + // Deps and links, with statuses. + if len(resp.Deps.BlockedBy) != 1 || resp.Deps.BlockedBy[0].Ref != f.blocker.Ref || resp.Deps.BlockedBy[0].Status != "open" { + t.Errorf("blocked_by = %+v", resp.Deps.BlockedBy) + } + if len(resp.Links) != 1 || resp.Links[0].Ref != f.related.Ref || resp.Links[0].Direction != "outgoing" { + t.Errorf("links = %+v", resp.Links) + } + + // Conflicts: the open blocker, and the role-scoped rule that shares a + // title with a global one on the same trigger. + kinds := map[string]bool{} + for _, c := range resp.Conflicts { + kinds[c.Kind] = true + } + if !kinds["open_blocker"] || !kinds["role_and_global_convention"] { + t.Errorf("conflicts = %+v", resp.Conflicts) + } + if len(resp.Omitted) != 0 { + t.Errorf("default budget omitted parts: %+v", resp.Omitted) + } + + // Without a role, role-scoped conventions are included, like bootstrap. + all, _, _ := f.context(f.ownerTok, "?intent=implement") + if ctxIndex(convRefs(all.Conventions), f.conv["impl-reviewer"].Ref) < 0 { + t.Errorf("no role: the reviewer rule should be included: %v", convRefs(all.Conventions)) + } + }) +} + +func TestPLAN3598_ItemContextRefusals(t *testing.T) { + forEachDialect(t, func(t *testing.T, d store.DriverType) { + f := newCtxFixture(t, d) + _, code, body := f.context(f.ownerTok, "?intent=nonsense") + if code != http.StatusBadRequest || !strings.Contains(body, "unknown_intent") || !strings.Contains(body, `"implement"`) { + t.Errorf("unknown intent: %d %s", code, body) + } + _, code, body = f.context(f.ownerTok, "?intent=implement&role=ghost") + if code != http.StatusBadRequest || !strings.Contains(body, "unknown_role") { + t.Errorf("unknown role: %d %s", code, body) + } + _, code, body = f.context(f.ownerTok, "?budget=-3") + if code != http.StatusBadRequest { + t.Errorf("bad budget: %d %s", code, body) + } + }) +} + +// A tiny budget keeps the item, omits the rest in order, and never lets a +// later part in once one was refused. +func TestPLAN3598_ItemContextBudgetIsOrderedAndWhole(t *testing.T) { + forEachDialect(t, func(t *testing.T, d store.DriverType) { + f := newCtxFixture(t, d) + full, _, _ := f.context(f.ownerTok, "?intent=implement") + itemOnly := partSize(full.Item) + + resp, code, body := f.context(f.ownerTok, "?intent=implement&budget=1") + if resp == nil { + t.Fatalf("status %d: %s", code, body) + } + if resp.Item.Ref != f.task.Ref || resp.Used != itemOnly { + t.Errorf("item must ship past the budget; used %d, item %d", resp.Used, itemOnly) + } + if len(resp.Parents)+len(resp.Conventions)+len(resp.Links)+len(resp.Deps.BlockedBy) != 0 { + t.Errorf("budget 1 shipped parts: %+v", resp) + } + if len(resp.Omitted) == 0 || resp.Omitted[0].Part != "parent" || resp.Omitted[0].Ref != f.plan.Ref || resp.Omitted[0].Size == 0 { + t.Errorf("omitted = %+v", resp.Omitted) + } + + // A budget that fits the item, the parent, and room for one small + // later part (a link) but not the first convention: the convention is + // refused, and the link, which would fit, must not slip in after it. + linkSize, convSize := partSize(full.Links[0]), partSize(full.Conventions[0]) + if linkSize >= convSize { + t.Fatalf("precondition: a link (%d) must be smaller than the first convention (%d)", linkSize, convSize) + } + mid := itemOnly + partSize(full.Parents[0]) + linkSize + resp, _, _ = f.context(f.ownerTok, "?intent=implement&budget="+itoa(mid)) + if len(resp.Parents) != 1 || len(resp.Conventions) != 0 || len(resp.Playbooks) != 0 || len(resp.Links) != 0 || len(resp.Deps.BlockedBy) != 0 || len(resp.Decisions) != 0 { + t.Errorf("mid budget: parents %d conventions %d playbooks %d links %d deps %d decisions %d", len(resp.Parents), len(resp.Conventions), len(resp.Playbooks), len(resp.Links), len(resp.Deps.BlockedBy), len(resp.Decisions)) + } + if len(resp.Omitted) == 0 || resp.Omitted[0].Part != "convention" || resp.Omitted[0].Ref != full.Conventions[0].Ref { + t.Errorf("mid budget omitted = %+v", resp.Omitted) + } + }) +} + +// A guest granted only the task sees nothing about the ancestor, blocker or +// linked item: not as parts, not in omitted, not in conflicts. +func TestPLAN3598_ItemContextGuestSeesOnlyGrantedItems(t *testing.T) { + forEachDialect(t, func(t *testing.T, d store.DriverType) { + f := newCtxFixture(t, d) + guest := mkUser(t, f.srv, "ctx-guest@example.com") + if _, err := f.srv.store.CreateItemGrant(f.wsID, f.task.ID, guest.ID, "view", f.owner.ID); err != nil { + t.Fatal(err) + } + tok := f.token(guest) + secrets := []string{f.plan.Ref, "Ctx plan", f.blocker.Ref, "Ctx blocker", f.related.Ref, "Ctx related"} + leaks := func(body string) []string { + var out []string + for _, s := range secrets { + if strings.Contains(body, s) { + out = append(out, s) + } + } + return out + } + _, code, body := f.context(tok, "?intent=implement&budget=1") + if code != http.StatusOK { + t.Fatalf("guest: %d %s", code, body) + } + if l := leaks(body); len(l) > 0 { + t.Errorf("tiny budget names ungranted items %v: %s", l, body) + } + _, _, body = f.context(tok, "?intent=implement") + if l := leaks(body); len(l) > 0 { + t.Errorf("brief names ungranted items %v: %s", l, body) + } + + // Control: granted, they appear. + for _, it := range []models.Item{f.plan, f.blocker, f.related} { + if _, err := f.srv.store.CreateItemGrant(f.wsID, it.ID, guest.ID, "view", f.owner.ID); err != nil { + t.Fatal(err) + } + } + _, _, body = f.context(tok, "?intent=implement") + if l := leaks(body); len(l) != len(secrets) { + t.Errorf("once granted, all should be named; named %v: %s", l, body) + } + + // An item the caller cannot see is the same 404 as one that does not exist. + other := mkUser(t, f.srv, "ctx-stranger@example.com") + stok := f.token(other) + hidden := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/"+f.task.Ref+"/context", stok, nil) + missing := f.do("GET", "/api/v1/workspaces/"+f.wsSlug+"/items/TASK-999999/context", stok, nil) + if hidden.Code != missing.Code || hidden.Body.String() != missing.Body.String() { + t.Errorf("hidden %d %s vs missing %d %s", hidden.Code, hidden.Body.String(), missing.Code, missing.Body.String()) + } + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6671254de..331581de4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2306,6 +2306,7 @@ func (s *Server) setupRouter() { // answer per question, each marked current or not. // Read-only; evaluation happens off the write path. r.Get("/decisions", s.handleListItemDecisions) + r.Get("/context", s.handleGetItemContext) }) // Links (v2)