feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b))

An agent rebuilt this context by hand: bootstrap, the item, its parent plan
or spec, linked items, and trigger-matched conventions (which need --full
for bodies). Each agent did it differently. The endpoint returns it in one
call, with no LLM in the loop:
- item: fields, body, seq;
- parents: the visible ancestor chain, nearest first, each with acceptance
  criteria parsed by internal/acceptance (#2091), and the nearest one's body;
- conventions whose trigger is always or the intent's, with bodies, sorted
  must, then should, then nice-to-have, then by ref;
- playbook metadata;
- deps with statuses, decisions with `current`, and linked items;
- omitted and conflicts.

The intent resolves against the workspace's declared trigger options (on-X,
then X); an unknown one is 400 unknown_intent with the valid list. The role
must be a slug and filters by the agent-role column (lead ruling).

The budget admits whole parts in a fixed order and stops at the first that
does not fit. That part and all later ones are listed in omitted, so a small
part never slips in after a refused one. The item always ships.

Visibility: the item uses requireItemVisible (hidden equals missing).
Ancestors, deps and links use itemVisibleFor plus the app-token collection
ceiling and live rows only; conventions use bootstrap's visibility sets.
Invisible parts are absent and not counted in omitted, so the endpoint is
not an existence oracle.

Conflicts are structural only: open_blocker, open_under_closed_parent,
stale_decision, role_and_global_convention.

Tests cover the brief, the refusals, budget order (the stop rule's mutant is
caught, after the first version of the test was found blind to it), a guest
seeing nothing ungranted with a positive control, and hidden-equals-missing.
CLI and MCP surfaces are slice (c).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-12 03:45:54 +00:00
parent 3631139de0
commit d10663cf3b
4 changed files with 905 additions and 0 deletions
+1
View File
@@ -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-<intent>` if declared, else `<intent>`; 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
+625
View File
@@ -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-<intent>" if declared, else "<intent>" 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)
}
@@ -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: <placeholder>\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())
}
})
}
+1
View File
@@ -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)