mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 14:57:15 +00:00
dd412a022d
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920) GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems (visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes. Snoozes are joined through items.workspace_id and read only for ids the caller already scoped by visibility (day-19 rulings on TASK-3920). Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920) Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive order over pure inputs (TestResolve_Order). WaitingSince is the earliest open claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The cursor carries as_of, view and ws and is position only. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920) Fans out over the caller's member and guest workspaces (token allow-list, and a token bound to one workspace stays in it), reads candidates under the caller's visibility, routes them with needs.Resolve, applies the caller's snoozes (joined through items.workspace_id), and pages a total order. degraded is set only on a provider error. Documented in docs/api.md. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(server): classify the needs inbox's route and lifecycle call (TASK-3920) Adds GET /me/needs to the compress census and its IsTerminalItem call to the PLAN-3535 lifecycle classification, and drops an unused helper and test field the linter found. Follows the previous commit; both ride the same PR. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920) The mentioned rule built one routing for every mentioned person, so a caller's row carried the earliest mention of anyone and other people's commenters and comment ids. Resolve now builds each person's view from their own mentions (Routing.For), and the handler takes the caller's. The attention read evaluated the whole workspace before visibility, so an error from an item the caller cannot see set degraded. A new Runner.WorkspaceFlagsForItems filters decision rows to the given ids before any state read; the handler passes the caller-visible ones. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920) The needs_human claim time was filled from every latest decision row, so a candidate whose row did not surface (below threshold, a no, stale) got a false needs_human reason whenever the workspace had any truly flagged item. Record and apply it only for flagged items. A cursor as_of later than now is clamped to now so the defer snapshot cannot be forged forward. Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920) Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
91 lines
2.8 KiB
Go
91 lines
2.8 KiB
Go
package needs
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// SortAge is the only sort so far: oldest-waiting first.
|
|
const SortAge = "age"
|
|
|
|
// Key is a row's place in the total order (waiting since, created, item id),
|
|
// ascending. The item id makes it total.
|
|
type Key struct {
|
|
Waiting time.Time
|
|
Created time.Time
|
|
ItemID string
|
|
}
|
|
|
|
// KeyOf is the row's sort key.
|
|
func KeyOf(r *Row) Key { return Key{r.WaitingSince, r.Item.CreatedAt, r.ItemID} }
|
|
|
|
// Less reports whether a sorts before b.
|
|
func (a Key) Less(b Key) bool {
|
|
if !a.Waiting.Equal(b.Waiting) {
|
|
return a.Waiting.Before(b.Waiting)
|
|
}
|
|
if !a.Created.Equal(b.Created) {
|
|
return a.Created.Before(b.Created)
|
|
}
|
|
return a.ItemID < b.ItemID
|
|
}
|
|
|
|
const cursorVersion = 1
|
|
|
|
// ErrInvalidCursor is any cursor the server will not follow: malformed, from
|
|
// another version, or minted for a different sort, view or workspace filter.
|
|
var ErrInvalidCursor = errors.New("invalid cursor")
|
|
|
|
// Cursor is a position in a result, and nothing more: it carries no
|
|
// authority. Every page re-runs the whole filtered pipeline and takes the rows
|
|
// strictly after this key, so a cursor naming an item the caller cannot see
|
|
// reveals nothing. AsOf is the time page 1 was computed, so a deferral that
|
|
// expires between pages does not reshuffle them.
|
|
type Cursor struct {
|
|
V int `json:"v"`
|
|
Sort string `json:"sort"`
|
|
View string `json:"view"`
|
|
WS string `json:"ws"`
|
|
AsOf int64 `json:"as_of"`
|
|
Waiting int64 `json:"key"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
ItemID string `json:"item_id"`
|
|
}
|
|
|
|
// NewCursor mints the cursor after row r.
|
|
func NewCursor(sort, view, ws string, asOf time.Time, r *Row) Cursor {
|
|
k := KeyOf(r)
|
|
return Cursor{V: cursorVersion, Sort: sort, View: view, WS: ws, AsOf: asOf.UnixNano(),
|
|
Waiting: k.Waiting.UnixNano(), CreatedAt: k.Created.UnixNano(), ItemID: k.ItemID}
|
|
}
|
|
|
|
// Encode is the opaque token form.
|
|
func (c Cursor) Encode() string {
|
|
b, _ := json.Marshal(c)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// Key is the position the cursor names.
|
|
func (c Cursor) Key() Key {
|
|
return Key{time.Unix(0, c.Waiting).UTC(), time.Unix(0, c.CreatedAt).UTC(), c.ItemID}
|
|
}
|
|
|
|
// At is the as_of time.
|
|
func (c Cursor) At() time.Time { return time.Unix(0, c.AsOf).UTC() }
|
|
|
|
// DecodeCursor parses a token and checks it was minted for this sort, view and
|
|
// workspace filter. Anything else is ErrInvalidCursor.
|
|
func DecodeCursor(token, sort, view, ws string) (Cursor, error) {
|
|
var c Cursor
|
|
b, err := base64.RawURLEncoding.DecodeString(token)
|
|
if err != nil || json.Unmarshal(b, &c) != nil {
|
|
return Cursor{}, ErrInvalidCursor
|
|
}
|
|
if c.V != cursorVersion || c.Sort != sort || c.View != view || c.WS != ws || c.ItemID == "" || c.AsOf <= 0 {
|
|
return Cursor{}, ErrInvalidCursor
|
|
}
|
|
return c, nil
|
|
}
|