mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
fix(server): item context decodes schemas canonically and treats reference items as no work (PLAN-3598 slice (b))
CI's Go job atd290fb37cfailed three guards in internal/models, a package my local gate did not run. - TestReservedFieldDecodeSitesAreClassified / TestGuardSeesEveryKnownDecodeSite: the trigger-option read and the done check decoded a collection schema with raw json.Unmarshal. Both read item fields, so both now use models.UnmarshalItemFieldSchema. - TestPLAN3535_LifecycleHelperCallSitesAreClassified: the new IsTerminalItem call was unclassified. Classifying it exposed a real defect. A reference item (a doc, in a collection that does not track work) read as OPEN, so a doc blocking the item raised open_blocker. CollectionTracksWork now answers first, as childProgressState's isReferenceCollection does, so a reference item is neither an open blocker nor a closed parent. The site is classified "exclude". The new reference-item test fails ond290fb37c. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
@@ -44,6 +44,7 @@ var lifecycleSites = map[string]string{
|
||||
"internal/server/handlers_dashboard.go::isItemDone::IsTerminalItem": "keep: the done predicate itself; each caller below is classified",
|
||||
"internal/server/handlers_dashboard.go::childProgressState::IsAbandonedItem": "exclude: the progress predicate; isReferenceCollection answers first",
|
||||
"internal/server/handlers_dashboard.go::childProgressState::IsTerminalItem": "exclude: the progress predicate; isReferenceCollection answers first",
|
||||
"internal/server/handlers_item_context.go::lifecycle::IsTerminalItem": "exclude: item-context conflicts (PLAN-3598); CollectionTracksWork answers first, so a reference item is neither an open blocker nor a closed parent",
|
||||
"internal/store/items.go::nonTerminalFilter::doneFiltersForWorkspace": "keep: the list filter for an item list's own open items (a doc's status filters its own list)",
|
||||
"internal/store/items.go::nonTerminalFilter::buildChildrenDoneExpr": "keep: as above",
|
||||
"internal/store/items.go::ListItems::nonTerminalFilter": "keep: `pad item list` / MCP list's default open filter, a list's own items",
|
||||
|
||||
@@ -230,7 +230,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
itemOpen := !sc.terminal(item)
|
||||
itemOpen := sc.openWork(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 {
|
||||
@@ -238,7 +238,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if i == 0 {
|
||||
p.Content = a.Content
|
||||
if itemOpen && sc.terminal(&a) {
|
||||
if itemOpen && sc.closedWork(&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"})
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func (s *Server) handleGetItemContext(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
depKind = append(depKind, kind)
|
||||
if !outgoing && itemOpen {
|
||||
if other, gerr := s.store.GetItem(otherID); gerr == nil && other != nil && !sc.terminal(other) {
|
||||
if other, gerr := s.store.GetItem(otherID); gerr == nil && other != nil && sc.openWork(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"})
|
||||
}
|
||||
@@ -415,7 +415,7 @@ func (s *Server) resolveContextIntent(conv *collections.TraitedCollection, inten
|
||||
}
|
||||
if c != nil {
|
||||
var schema models.CollectionSchema
|
||||
if json.Unmarshal([]byte(c.Schema), &schema) == nil {
|
||||
if models.UnmarshalItemFieldSchema([]byte(c.Schema), &schema) == nil {
|
||||
for _, f := range schema.Fields {
|
||||
if f.Key == "trigger" {
|
||||
options = f.Options
|
||||
@@ -599,7 +599,8 @@ func contextFieldString(fieldsJSON, key string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// contextSchemaCache answers "is this item closed?" per collection, with the
|
||||
// contextSchemaCache answers "is this item open work, closed work, or not
|
||||
// work at all?" per collection: CollectionTracksWork first, then the
|
||||
// done-field-aware check (models.IsTerminalItem).
|
||||
type contextSchemaCache struct {
|
||||
s *Server
|
||||
@@ -616,24 +617,42 @@ func newContextSchemaCache(s *Server) *contextSchemaCache {
|
||||
return &contextSchemaCache{s: s, by: map[string]*contextSchema{}}
|
||||
}
|
||||
|
||||
func (c *contextSchemaCache) terminal(it *models.Item) bool {
|
||||
func (c *contextSchemaCache) load(it *models.Item) *contextSchema {
|
||||
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
|
||||
cs.ok = models.UnmarshalItemFieldSchema([]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
|
||||
return cs
|
||||
}
|
||||
|
||||
// lifecycle reports whether the item is work at all (a reference item, in a
|
||||
// collection that does not track work, is neither open nor closed: PLAN-3535)
|
||||
// and, if so, whether it is done.
|
||||
func (c *contextSchemaCache) lifecycle(it *models.Item) (tracksWork, done bool) {
|
||||
cs := c.load(it)
|
||||
if !cs.ok || !models.CollectionTracksWork(cs.settings) {
|
||||
return false, false
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(it.Fields), &m) != nil {
|
||||
return false
|
||||
return true, false
|
||||
}
|
||||
return models.IsTerminalItem(m, cs.schema, cs.settings)
|
||||
return true, models.IsTerminalItem(m, cs.schema, cs.settings)
|
||||
}
|
||||
|
||||
func (c *contextSchemaCache) openWork(it *models.Item) bool {
|
||||
work, done := c.lifecycle(it)
|
||||
return work && !done
|
||||
}
|
||||
|
||||
func (c *contextSchemaCache) closedWork(it *models.Item) bool {
|
||||
work, done := c.lifecycle(it)
|
||||
return work && done
|
||||
}
|
||||
|
||||
@@ -308,3 +308,37 @@ func TestPLAN3598_ItemContextArchivedFollowsGet(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A reference item (in a collection that does not track work, PLAN-3535) is
|
||||
// neither open nor closed: a doc that blocks the item raises no open_blocker.
|
||||
func TestPLAN3598_ItemContextReferenceItemIsNotOpenWork(t *testing.T) {
|
||||
forEachDialect(t, func(t *testing.T, d store.DriverType) {
|
||||
f := newCtxFixture(t, d)
|
||||
doc := f.itemIn("docs", map[string]any{"title": "Ctx reference doc"})
|
||||
if _, err := f.srv.store.CreateItemLink(f.wsID, models.ItemLinkCreate{TargetID: f.task.ID, LinkType: models.ItemLinkTypeBlocks}, doc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, code, body := f.context(f.ownerTok, "?intent=implement")
|
||||
if resp == nil {
|
||||
t.Fatalf("status %d: %s", code, body)
|
||||
}
|
||||
var sawDoc, sawTask bool
|
||||
for _, b := range resp.Deps.BlockedBy {
|
||||
sawDoc = sawDoc || b.Ref == doc.Ref
|
||||
}
|
||||
for _, c := range resp.Conflicts {
|
||||
if c.Kind != "open_blocker" {
|
||||
continue
|
||||
}
|
||||
for _, r := range c.Refs {
|
||||
if r == doc.Ref {
|
||||
t.Errorf("a reference doc was reported as an open blocker: %+v", c)
|
||||
}
|
||||
sawTask = sawTask || r == f.blocker.Ref
|
||||
}
|
||||
}
|
||||
if !sawDoc || !sawTask {
|
||||
t.Errorf("precondition: the doc should be listed as a blocker (%v) and the open task blocker still conflict (%v)", sawDoc, sawTask)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user