mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user