Files
xarmian 16aaaf5450 feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919)

A collection whose items are asks of a person (IDEA-3906): the needs-you
router will treat an open, assigned item in it as "asked". Stored as
settings.awaits_human like tracks_work: absent means false, a settings write
that omits it keeps the stored value, a typed member on create and update
sets it alone, and a non-boolean is refused 400. No migration (A3 default).

- models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool
  setting helpers that the tracks_work functions now use too
- store: create and update apply it; update carries it under the row lock
- CLI: pad collection update --awaits-human[=false], checked on the response
- web: "Items are asks of a person" toggle on the editor's Display tab
- docs/api.md

Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX

* fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919)

A plain bool field made a stored non-boolean awaits_human (an import is not
validated) fail the whole CollectionSettings unmarshal, so a reference
collection read as work. CollectionAwaitsHumanJSON now reads the one key.
Also corrects the --awaits-human comment: the flag shape is not MCP-safe.

Claude-Session: https://claude.ai/code/session_01EJctmVZwefvYhqSRSJhNKX

* test(store): assert the returned settings after turning awaits_human off (TASK-3919)

staticcheck SA4006: the UpdateCollection result was assigned and never read.

Claude-Session: https://claude.ai/code/session_01Fbkk7f7M5JqZE1Gc4ZSSYj

* fix(cli): pad collection create takes --awaits-human (TASK-3919)

Codex r2 P2: only `collection update` had the flag, so marking a
human-tasks collection took a second call. create now sends the typed
member and, like update, refuses to report success when an older server
ignored it.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-19 19:58:46 -04:00

131 lines
4.7 KiB
Go

package models
import (
"encoding/json"
"errors"
"strings"
)
// PLAN-3535: a collection either tracks WORK (its items have a lifecycle and
// finish) or holds REFERENCE material (its items just exist: docs,
// conventions, playbooks). A reference collection's items do not count toward
// parent progress, do not block a parent from closing, and stay out of
// Insights and the agent/dashboard open-work counts. They still count in the
// sidebar, search, links and their own collection list.
//
// Stored as CollectionSettings.TracksWork (JSON `tracks_work`). ABSENT means
// work, today's behaviour, so an old row, an API writer that never sends the
// key, and a client/server skew all keep counting as before. Every reader
// goes through CollectionTracksWork / CollectionTracksWorkJSON; nothing reads
// the key itself.
// CollectionTracksWork reports whether a collection's items count as work.
func CollectionTracksWork(settings CollectionSettings) bool {
return settings.TracksWork == nil || *settings.TracksWork
}
// CollectionTracksWorkJSON is CollectionTracksWork over the stored settings
// JSON. Unparseable settings count as work (today's behaviour).
func CollectionTracksWorkJSON(settingsJSON string) bool {
if strings.TrimSpace(settingsJSON) == "" {
return true
}
var s CollectionSettings
if err := json.Unmarshal([]byte(settingsJSON), &s); err != nil {
return true
}
return CollectionTracksWork(s)
}
// DefaultTracksWork is the value a NEW collection gets when its creator did
// not say: a system collection (Conventions, Playbooks) is reference; any
// other collection is work when its items can FINISH, that is, when its done
// field is a select and one of its options is terminal. Terminal means what
// every lifecycle reader means (TerminalValuesForDoneField): the declared
// terminal_options, or DefaultTerminalStatuses when the field declares none,
// so `status: open, done` with no terminal_options is work. Otherwise
// reference.
func DefaultTracksWork(schemaJSON, settingsJSON string, isSystem bool) bool {
if isSystem {
return false
}
var schema CollectionSchema
if err := UnmarshalItemFieldSchema([]byte(schemaJSON), &schema); err != nil {
return true
}
var settings CollectionSettings
_ = json.Unmarshal([]byte(settingsJSON), &settings)
key, terminals := TerminalValuesForDoneField(schema, settings)
for _, f := range schema.Fields {
if f.Key != key || f.Type != "select" {
continue
}
for _, opt := range f.Options {
for _, t := range terminals {
if strings.EqualFold(opt, t) {
return true
}
}
}
return false
}
return false
}
// WithTracksWorkDefault returns settingsJSON with `tracks_work` set to
// DefaultTracksWork when the creator did not set it; settings that already
// carry the key, or that do not parse as an object, are returned unchanged.
// Used on collection CREATE only (not import, which keeps what it carries).
func WithTracksWorkDefault(settingsJSON, schemaJSON string, isSystem bool) string {
raw := strings.TrimSpace(settingsJSON)
if raw == "" {
raw = "{}"
}
var m map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &m); err != nil || m == nil {
return settingsJSON
}
if _, ok := m["tracks_work"]; ok {
return settingsJSON
}
v := "false"
if DefaultTracksWork(schemaJSON, raw, isSystem) {
v = "true"
}
m["tracks_work"] = json.RawMessage(v)
out, err := json.Marshal(m)
if err != nil {
return settingsJSON
}
return string(out)
}
// CarryTracksWork returns newSettingsJSON with the stored `tracks_work` value
// carried over when the new settings do not mention the key (PLAN-3535).
// Settings are written wholesale, and every client written before the key
// existed rebuilds them without it, so absence on UPDATE means "not
// mentioned", never "reset to work". A new value that does set the key wins.
// Anything that does not parse as an object is returned unchanged.
func CarryTracksWork(newSettingsJSON, storedSettingsJSON string) string {
return carryBoolSetting(newSettingsJSON, storedSettingsJSON, "tracks_work")
}
// ValidateTracksWorkSetting refuses a `tracks_work` that is present but not a
// JSON boolean. An absent key is fine (it means work on create, "unchanged"
// on update).
func ValidateTracksWorkSetting(settingsJSON string) error {
if !boolSettingValid(settingsJSON, "tracks_work") {
return errTracksWorkNotBool
}
return nil
}
var errTracksWorkNotBool = errors.New("settings.tracks_work must be true or false")
// SetTracksWork returns settingsJSON with tracks_work set to v and every other
// key unchanged. Settings that do not parse as an object are an error: the
// caller asked for a value it cannot store.
func SetTracksWork(settingsJSON string, v bool) (string, error) {
return setBoolSetting(settingsJSON, "tracks_work", v)
}