feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)

* watchevents: add session-targeted push delivery predicate

PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.

* server: accept target_session_id on push, report delivered_sessions

PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.

* web: session picker in the push composer, targeted-miss handling

PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.

* server: bound target_session_id, skip publish on a targeted miss

Codex round 1 fixes for TASK-2588:

- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
  caller can't park arbitrary garbage in the bus's shared replay buffer;
  a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
  order raced a target disconnecting between publish and count, which
  could report delivered_sessions=0 on a push that had already landed
  once. A targeted push now skips the publish entirely when its id
  isn't in the pre-publish snapshot — session ids are per-connection
  and never reused, so a target absent now can never be matched later,
  making the 0 a guarantee rather than a race. Broadcast is unaffected
  (still publish-always, pre-publish count).

Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.

* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses

Codex round 2 dispositions for TASK-2588:

- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
  moved the ruling from a test comment onto the contract itself —
  pushResponse.Pushed's own doc comment in Go, mirrored in the TS
  ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
  session, a <select> can visually fall back to "All connected
  sessions" while the bound value stays the stale id, so the wire
  would carry a dead target the UI no longer shows as selected.
  Added reconcileSelectedSession(), called at every point `sessions`
  is reassigned outside the fresh-open reset (a live poll, a failed
  read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
  negotiation (the deployment shape — web assets embedded in the
  server binary — bounds this to a transient stale tab, argument
  recorded in the comment): delivered_sessions is now optional on the
  wire type, and a targeted send whose response omits it entirely is
  treated as UNKNOWN (info toast, dismiss like a normal success) —
  never inferred as a confirmed miss.

Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.

* push targeting: fix stale publish-guarantee comments (codex round 3)

Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:

- watchevents.KindPush's doc comment ("publishes exactly one of
  these") now notes handlePushToItem decides whether to publish at
  all, and points at TargetSessionID / pushResponse.DeliveredSessions
  for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
  promise means "published to the bus" unconditionally — a targeted
  miss resolves with delivered_sessions: 0 and nothing published.

Comment-only; no behavior change.
This commit is contained in:
xarmian
2026-08-15 14:52:25 -04:00
committed by GitHub
parent d7da237198
commit 00a91dfcf4
9 changed files with 984 additions and 73 deletions
+142 -22
View File
@@ -13,6 +13,15 @@ import (
// pushRequest is the body of POST .../items/{itemSlug}/push.
type pushRequest struct {
Message string `json:"message"`
// TargetSessionID optionally narrows delivery to one of the caller's
// OWN live sessions from the S1 presence registry (PLAN-2558 S5,
// TASK-2588; GET /api/v1/sessions is where a caller learns the id).
// Omitted (the pre-S5 shape) means broadcast to every one of the
// caller's connected sessions, unchanged. API + TS client + web
// picker only in this slice, per CONVE-1741 — no CLI flag, no MCP
// surface; internal/cli's PushResult mirror simply never sends or
// reads this field.
TargetSessionID string `json:"target_session_id,omitempty"`
}
// pushResponse is the body of a successful push (dispatcher review round
@@ -24,10 +33,51 @@ type pushRequest struct {
type pushResponse struct {
Ref string `json:"ref"`
Workspace string `json:"workspace"`
Pushed bool `json:"pushed"`
Message string `json:"message"`
// Pushed means "accepted and processed", not "delivered" — it is
// true even when a TARGETED push's publish was skipped because the
// id matched no live session (dispatcher ruling, TASK-2588 round 2:
// broadcast-with-no-listeners has always returned exactly this shape
// — true, with nothing to receive it — and a targeted miss is not
// given a different contract just because DeliveredSessions can now
// say more about it). DeliveredSessions is the delivery signal; do
// not read Pushed as one.
Pushed bool `json:"pushed"`
Message string `json:"message"`
// DeliveredSessions counts how many of the caller's own live sessions
// (S1 presence registry, `target_session_id`-filtered if one was
// given) matched — PLAN-2558 S5, TASK-2588. This is a PREDICTION read
// from the registry, not a delivery receipt: it carries the exact
// same staleness window as GET /api/v1/sessions (session_presence.go's
// LiveSession doc comment — up to ~30s behind an ungracefully-dropped
// connection) and there is still no ack from the receiving side. A
// vanished or cross-user target_session_id is 0, the same as "nothing
// connected" — deliberately not a distinct error, so the CLI's pre-S5
// behavior is unchanged by construction.
//
// SNAPSHOTTED BEFORE THE PUBLISH, not after (dispatcher review round
// 1, codex): counting post-publish raced the very thing it reports on
// — a targeted session could receive the notification and then
// disconnect before the count read, reporting 0 on a push that had
// already landed exactly once. handlePushToItem reads presence FIRST
// and, for a targeted push, skips the publish entirely when the
// target isn't in that snapshot — see its doc comment — so a 0 here
// is never a race, it's a guarantee: nothing was sent.
DeliveredSessions int `json:"delivered_sessions"`
}
// maxPushTargetSessionIDLen bounds target_session_id (dispatcher review
// round 1, codex). It's trimmed but otherwise unvalidated — see its doc
// comment in pushRequest — and decodeJSON allows request bodies up to
// 2 MiB, so without a cap an authenticated caller could park arbitrarily
// large garbage strings in the bus's 1024-entry replay buffer on every
// push. 256 runes is comfortably above any id the S1 presence registry
// actually issues (a uuid.NewString() is 36) — a registry-issued id can
// never be rejected by this bound, so nothing a real client sends is
// ever affected; this exists purely to keep an unmatchable payload out
// of shared memory, not to constrain the id format (still opaque, still
// no format enforced beyond length).
const maxPushTargetSessionIDLen = 256
// maxPushMessageLen bounds a push's instruction text, measured in runes
// AFTER whitespace collapse (dispatcher review round 1). Two
// constraints in tension set this: a push message is a free-form
@@ -49,15 +99,20 @@ const maxPushMessageLen = 4096
// notification (IDEA-2544 Phase 1) — the "push this to my agent" verb:
// an explicit, user-authored instruction bound to an item, delivered to
// every one of the pushing user's OWN connected monitor sessions via
// GET /api/v1/events/stream. Unlike watch/assignment notifications,
// this has no durable backing (Dave's product call: fire-and-forget is
// acceptable for v1 — no inbox, no "no session connected" warning; the
// bus's replay buffer is the only resilience a push gets).
// GET /api/v1/events/stream, or to exactly one of them when the request
// names a target_session_id (PLAN-2558 S5, TASK-2588). Unlike watch/
// assignment notifications, this has no durable backing (Dave's product
// call: fire-and-forget is acceptable for v1 — no inbox, no "no session
// connected" warning; the bus's replay buffer is the only resilience a
// push gets).
//
// Self-addressed only: pushing into someone else's session is a consent
// question, not a code question (IDEA-2544 plan), so TargetUserID is
// always set to the CALLER's own ID, never a request-supplied target —
// there is no cross-user push in Phase 1.
// there is no cross-user push. TargetSessionID (S5) does not relax this:
// it can only narrow delivery WITHIN the sessions ListForUser(userID)
// already scopes to, never address a session outside it — see
// deliveredSessionCount.
//
// POST /api/v1/workspaces/{slug}/items/{itemSlug}/push
func (s *Server) handlePushToItem(w http.ResponseWriter, r *http.Request) {
@@ -129,23 +184,88 @@ func (s *Server) handlePushToItem(w http.ResponseWriter, r *http.Request) {
actor, _ := actorFromRequest(r)
actorName := actorNameFromRequest(r)
// Trimmed, not otherwise validated beyond the length cap below: a
// session id is opaque to this handler (see deliveredSessionCount and
// Notification.TargetSessionID) — an id that names no live session of
// userID's just matches nothing, there is no format to enforce.
targetSessionID := strings.TrimSpace(input.TargetSessionID)
if length := len([]rune(targetSessionID)); length > maxPushTargetSessionIDLen {
writeError(w, http.StatusBadRequest, "bad_request",
fmt.Sprintf("target_session_id must be %d characters or fewer (got %d)", maxPushTargetSessionIDLen, length))
return
}
s.watchEvents.Publish(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: item.ID,
CollectionID: item.CollectionID,
ItemRef: item.Ref,
Kind: watchevents.KindPush,
Actor: actor,
ActorName: actorName,
Summary: message,
TargetUserID: userID,
})
// Presence is read BEFORE the publish, not after (dispatcher review
// round 1, codex — see DeliveredSessions' doc comment for the race a
// post-publish count had). For a TARGETED push whose id isn't in this
// snapshot, the publish is skipped entirely: session ids are
// per-connection and never reused (session_presence.go's
// MemorySessionPresence.Add mints a fresh uuid per Add call), so a
// target absent right now can never later be matched by the SAME
// connection reconnecting under that id, nor by the bus's replay
// buffer (which only serves a resumed connection presenting its own
// prior Last-Event-ID, not an arbitrary target id). The notification
// would therefore be a guaranteed no-op — skipping it is what makes
// delivered_sessions=0 an honest guarantee rather than a snapshot
// that a slower reader could still race. Broadcast (targetSessionID
// == "") keeps the original fire-and-forget posture and always
// publishes, same as pre-S5 — its count is a pre-publish snapshot of
// who's connected, not a promise that count still holds by the time
// delivery happens, which is the same staleness every presence
// answer on this surface already carries.
deliveredSessions := deliveredSessionCount(s.sessionPresence, userID, targetSessionID)
if targetSessionID == "" || deliveredSessions > 0 {
s.watchEvents.Publish(watchevents.Notification{
WorkspaceID: workspaceID,
ItemID: item.ID,
CollectionID: item.CollectionID,
ItemRef: item.Ref,
Kind: watchevents.KindPush,
Actor: actor,
ActorName: actorName,
Summary: message,
TargetUserID: userID,
TargetSessionID: targetSessionID,
})
}
writeJSON(w, http.StatusOK, pushResponse{
Ref: item.Ref,
Workspace: ws.Slug,
Pushed: true,
Message: message,
Ref: item.Ref,
Workspace: ws.Slug,
Pushed: true,
Message: message,
DeliveredSessions: deliveredSessions,
})
}
// deliveredSessionCount answers "how many of userID's own live sessions
// will this push's delivery predicate match?" (PLAN-2558 S5, TASK-2588).
// It reads the SAME self-scoped list session_presence.go's
// SessionPresence.ListForUser already restricts every other consumer to
// (handlers_sessions.go's GET /api/v1/sessions is the other one) — that
// scoping is what makes a targetSessionID belonging to a DIFFERENT user
// structurally indistinguishable from a vanished one: it is simply never
// in userID's own list, so it falls out to 0 without any cross-user
// lookup or special-casing here.
//
// A nil presence (no registry wired) answers 0 rather than guessing —
// consistent with handleListSessions' own refusal to report an empty
// list as "nobody connected" when it genuinely cannot tell; here there
// is no error channel to say "can't tell" through (pushResponse.Pushed
// is still true — the notification really was published), so 0 is the
// closest honest answer available.
func deliveredSessionCount(presence SessionPresence, userID, targetSessionID string) int {
if presence == nil {
return 0
}
sessions := presence.ListForUser(userID)
if targetSessionID == "" {
return len(sessions)
}
for _, sess := range sessions {
if sess.ID == targetSessionID {
return 1
}
}
return 0
}
+293
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
@@ -191,3 +192,295 @@ func TestPushToItem_InvisibleItemDenied(t *testing.T) {
t.Fatalf("expected 404, got %d (body: %s)", rr.Code, rr.Body.String())
}
}
// TestPushToItem_TargetedSessionReceivesOnly is PLAN-2558 S5's (TASK-2588)
// core positive case: with TWO of the caller's own sessions connected, a
// push naming one of their ids reaches exactly that one, not the other,
// and delivered_sessions reports 1 — not the registry size of 2.
func TestPushToItem_TargetedSessionReceivesOnly(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
ts := httptest.NewServer(srv)
defer ts.Close()
ctxA, cancelA := context.WithCancel(context.Background())
defer cancelA()
chA := connectWatchStream(ctxA, t, ts.URL, tok.Token)
waitForWatchEvent(t, chA, 3*time.Second) // connected — A's Add() has happened
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelB()
chB := connectWatchStream(ctxB, t, ts.URL, tok.Token)
waitForWatchEvent(t, chB, 3*time.Second) // connected — B's Add() has happened, strictly after A's
status, sessions := getSessions(t, ts.URL, tok.Token)
if status != http.StatusOK || len(sessions.Sessions) != 2 {
t.Fatalf("expected 2 live sessions, got status=%d sessions=%+v", status, sessions)
}
// ListForUser orders oldest-connection-first (session_presence.go), and
// A connected strictly before B, so sessions[0] is A's own id.
targetID := sessions.Sessions[0].ID
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
map[string]interface{}{"message": "for A only", "target_session_id": targetID})
if rr.Code != http.StatusOK {
t.Fatalf("push: %d %s", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.DeliveredSessions != 1 {
t.Fatalf("expected delivered_sessions=1 for a session-targeted push, got %d", resp.DeliveredSessions)
}
ev := waitForWatchEvent(t, chA, 3*time.Second)
var payload watchEventPayload
if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
t.Fatalf("parse payload: %v", err)
}
if payload.Summary != "for A only" {
t.Fatalf("expected the targeted session to receive the push, got summary %q", payload.Summary)
}
assertNoWatchEventForRef(t, chB, item.Ref, 300*time.Millisecond)
}
// TestPushToItem_TargetedVanishedSessionMisses is S5's honest-miss case:
// a target_session_id that names no live session (mistyped, or expired)
// gets a 200 with delivered_sessions=0, never mis-delivered to a session
// that IS connected but wasn't the one addressed.
func TestPushToItem_TargetedVanishedSessionMisses(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
ts := httptest.NewServer(srv)
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := connectWatchStream(ctx, t, ts.URL, tok.Token)
waitForWatchEvent(t, ch, 3*time.Second) // connected
// Bus growth, not just "this OTHER session didn't get it" (dispatcher
// review round 1, codex): a targeted miss must skip the publish
// entirely, not merely fail to match anyone downstream — see
// pushResponse.DeliveredSessions' doc comment on why. This is the
// seam that fails if the pre-publish snapshot-and-skip ever gets
// reordered back to publish-then-count.
before := len(srv.watchEvents.EventsSince(0))
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
map[string]interface{}{"message": "nobody home", "target_session_id": "sess-does-not-exist"})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for a vanished target (honest miss, not an error), got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.DeliveredSessions != 0 {
t.Fatalf("expected delivered_sessions=0 for a vanished target, got %d", resp.DeliveredSessions)
}
if !resp.Pushed {
t.Fatal("expected pushed=true — a targeted miss is still a successfully PROCESSED push (matching broadcast's existing no-listeners semantics), even though nothing was published to the bus")
}
if after := len(srv.watchEvents.EventsSince(0)); after != before {
t.Fatalf("expected a targeted miss to skip the publish entirely (guaranteed no-op), but the bus grew from %d to %d entries", before, after)
}
assertNoWatchEventForRef(t, ch, item.Ref, 300*time.Millisecond)
}
// TestPushToItem_TargetedSessionOfAnotherUserTreatedAsVanished pins the
// self-addressed boundary (dispatcher constraint, TASK-2588): a
// target_session_id that names a REAL, currently-connected session
// belonging to a DIFFERENT user must behave exactly like a vanished id —
// 200, delivered_sessions=0 — never leak to that other user's session and
// never let the pusher probe whether a given id exists on the server.
func TestPushToItem_TargetedSessionOfAnotherUserTreatedAsVanished(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tokA, _ := setupWatchTestUser(t, srv)
userB, err := srv.store.CreateUser(models.UserCreate{
Email: "push-target-test-b@example.com",
Name: "Push Target Tester B",
Password: "pw-test-12345",
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tokB, err := srv.store.CreateAPIToken(userB.ID, models.APITokenCreate{Name: "push-target-test-b"}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
ts := httptest.NewServer(srv)
defer ts.Close()
ctxA, cancelA := context.WithCancel(context.Background())
defer cancelA()
chA := connectWatchStream(ctxA, t, ts.URL, tokA.Token)
waitForWatchEvent(t, chA, 3*time.Second)
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelB()
chB := connectWatchStream(ctxB, t, ts.URL, tokB.Token)
waitForWatchEvent(t, chB, 3*time.Second)
_, bSessions := getSessions(t, ts.URL, tokB.Token)
if len(bSessions.Sessions) != 1 {
t.Fatalf("expected user B to see exactly 1 live session, got %+v", bSessions)
}
bSessionID := bSessions.Sessions[0].ID
// See TestPushToItem_TargetedVanishedSessionMisses: a cross-user id
// must skip the publish exactly like a genuinely vanished one — the
// bus must not grow, not merely fail to reach B downstream.
before := len(srv.watchEvents.EventsSince(0))
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tokA.Token,
map[string]interface{}{"message": "should reach nobody", "target_session_id": bSessionID})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.DeliveredSessions != 0 {
t.Fatalf("expected delivered_sessions=0 for another user's session id — a cross-user id must be indistinguishable from a vanished one, got %d", resp.DeliveredSessions)
}
if after := len(srv.watchEvents.EventsSince(0)); after != before {
t.Fatalf("expected a cross-user target to skip the publish entirely, but the bus grew from %d to %d entries", before, after)
}
assertNoWatchEventForRef(t, chA, item.Ref, 300*time.Millisecond)
assertNoWatchEventForRef(t, chB, item.Ref, 300*time.Millisecond)
}
// TestPushToItem_TargetSessionIDOverLengthRejected covers the 400-over-cap
// guard (dispatcher review round 1, codex): target_session_id is opaque
// and unvalidated by format, but not unbounded — decodeJSON allows request
// bodies up to 2 MiB, so without a length cap an authenticated caller
// could park arbitrarily large garbage strings in the bus's replay
// buffer on every push. The bus must not grow, matching the "reject
// before publish" posture the length check shares with the message cap.
func TestPushToItem_TargetSessionIDOverLengthRejected(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
before := len(srv.watchEvents.EventsSince(0))
overLong := strings.Repeat("a", maxPushTargetSessionIDLen+1)
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
map[string]interface{}{"message": "triage this", "target_session_id": overLong})
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for an over-cap target_session_id, got %d (body: %s)", rr.Code, rr.Body.String())
}
if after := len(srv.watchEvents.EventsSince(0)); after != before {
t.Fatalf("expected an over-cap target_session_id to be rejected before publish, but the bus grew from %d to %d entries", before, after)
}
}
// TestPushToItem_TargetSessionIDAtCapAccepted is the at-boundary
// counterpart: a target_session_id exactly at the cap must NOT be
// rejected (it is still a miss — nothing that long is a real registry
// id — but the length check itself must not be off-by-one).
func TestPushToItem_TargetSessionIDAtCapAccepted(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
atCap := strings.Repeat("a", maxPushTargetSessionIDLen)
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
map[string]interface{}{"message": "triage this", "target_session_id": atCap})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for a target_session_id exactly at the cap, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.DeliveredSessions != 0 {
t.Fatalf("expected delivered_sessions=0 — an at-cap id still names no live session, got %d", resp.DeliveredSessions)
}
}
// TestPushToItem_BroadcastDeliveredSessionsCountsLiveSessions covers the
// broadcast (omitted target_session_id) mode with delivered_sessions
// wired up: it reaches every one of the caller's own connected sessions,
// and the count is the actual number that matched — 2, from
// ListForUser(userID) — not any GLOBAL registry size.
func TestPushToItem_BroadcastDeliveredSessionsCountsLiveSessions(t *testing.T) {
t.Parallel()
srv := testServerWithPresence(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
ts := httptest.NewServer(srv)
defer ts.Close()
ctxA, cancelA := context.WithCancel(context.Background())
defer cancelA()
chA := connectWatchStream(ctxA, t, ts.URL, tok.Token)
waitForWatchEvent(t, chA, 3*time.Second)
ctxB, cancelB := context.WithCancel(context.Background())
defer cancelB()
chB := connectWatchStream(ctxB, t, ts.URL, tok.Token)
waitForWatchEvent(t, chB, 3*time.Second)
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
map[string]interface{}{"message": "for everyone"})
if rr.Code != http.StatusOK {
t.Fatalf("push: %d %s", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.DeliveredSessions != 2 {
t.Fatalf("expected delivered_sessions=2 for a broadcast with 2 live sessions, got %d", resp.DeliveredSessions)
}
for _, ch := range []<-chan watchSSEEvent{chA, chB} {
ev := waitForWatchEvent(t, ch, 3*time.Second)
var payload watchEventPayload
if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
t.Fatalf("parse payload: %v", err)
}
if payload.Summary != "for everyone" {
t.Fatalf("expected both sessions to receive the broadcast push, got summary %q", payload.Summary)
}
}
}
// TestPushToItem_OmittedTargetSessionIDMatchesPreS5RequestShape is the
// wire-level compatibility leg (dispatcher plan): a request body in
// EXACTLY the pre-S5 shape — no target_session_id key at all, not even
// an empty string — must still be accepted and behave as pure broadcast,
// against a server with no presence registry at all (mirroring every
// pre-S5 push test's setup via testServerWithWatchEvents). A vanished
// registry answers delivered_sessions=0 honestly rather than erroring —
// there is no new error channel for a caller that never asked to target
// anything.
func TestPushToItem_OmittedTargetSessionIDMatchesPreS5RequestShape(t *testing.T) {
t.Parallel()
srv := testServerWithWatchEvents(t) // NOT testServerWithPresence — no registry
slug, item, tok, _ := setupWatchTestUser(t, srv)
rr := bearerCall(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/push", tok.Token,
[]byte(`{"message":"triage this"}`))
if rr.Code != http.StatusOK {
t.Fatalf("push: %d %s", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
if resp.Ref != item.Ref || resp.Workspace != slug || !resp.Pushed || resp.Message != "triage this" {
t.Fatalf("pre-S5 fields must be unchanged for a pre-S5 request body, got %+v", resp)
}
if resp.DeliveredSessions != 0 {
t.Fatalf("expected delivered_sessions=0 with no presence registry wired, got %d", resp.DeliveredSessions)
}
}
+32 -7
View File
@@ -141,8 +141,16 @@ func (s *Server) handleWatchEventsStream(w http.ResponseWriter, r *http.Request)
// The identity is whatever the client claimed in its request
// headers (S2, TASK-2560) — sanitized, never verified, and empty
// for any client that doesn't say. See SessionIdentity.
// Hoisted out of the if-block (PLAN-2558 S5, TASK-2588): a targeted
// push's predicate (Notification.TargetSessionID) is evaluated in the
// select loop below, which needs this connection's own registry id in
// scope. Left as the zero value when there is no registry — a
// targeted push can then never match THIS connection, but an
// untargeted (broadcast) one is unaffected, since watchNotificationVisible
// only compares TargetSessionID when the notification actually set one.
var sessionID string
if s.sessionPresence != nil {
sessionID := s.sessionPresence.Add(user.ID, parseSessionIdentity(r))
sessionID = s.sessionPresence.Add(user.ID, parseSessionIdentity(r))
defer s.sessionPresence.Remove(user.ID, sessionID)
}
@@ -182,7 +190,7 @@ func (s *Server) handleWatchEventsStream(w http.ResponseWriter, r *http.Request)
flusher.Flush()
} else {
for _, n := range missed {
if !watchNotificationVisible(watches, visCache.forWorkspace(n.WorkspaceID), user.ID, n) {
if !watchNotificationVisible(watches, visCache.forWorkspace(n.WorkspaceID), user.ID, sessionID, n) {
continue
}
if err := writeSSEEvent(w, "notification", n.ID, watchEventPayloadFor(s, n)); err != nil {
@@ -216,7 +224,7 @@ func (s *Server) handleWatchEventsStream(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
if !watchNotificationVisible(watches, visCache.forWorkspace(n.WorkspaceID), user.ID, n) {
if !watchNotificationVisible(watches, visCache.forWorkspace(n.WorkspaceID), user.ID, sessionID, n) {
continue
}
if err := writeSSEEvent(w, "notification", n.ID, watchEventPayloadFor(s, n)); err != nil {
@@ -413,7 +421,13 @@ func (s *Server) loadWatchPredicates(r *http.Request, userID string) (map[string
// handler so the DR-2 filtering rules are unit-testable without a live
// SSE connection — mirrors sseEventVisibleFor's role in
// handlers_events.go.
func watchNotificationVisible(watches map[string]string, vis watchAccessVisibility, userID string, n watchevents.Notification) bool {
//
// sessionID is THIS connection's own S1 presence-registry id (empty if
// the server has no presence registry wired) — the same id
// Notification.TargetSessionID's doc comment describes evaluating
// against (PLAN-2558 S5, TASK-2588). Unused by every kind except
// KindPush.
func watchNotificationVisible(watches map[string]string, vis watchAccessVisibility, userID string, sessionID string, n watchevents.Notification) bool {
// TASK-2533 codex round 2 finding 2: the caller's CURRENT access to
// the notification's collection/item is checked FIRST and applies
// UNIFORMLY to every kind below — watch-matched and addressed-to-you
@@ -469,12 +483,23 @@ func watchNotificationVisible(watches map[string]string, vis watchAccessVisibili
// original code fell through to the watch-map check on a
// non-matching TargetUserID, so any unconditional watcher on the item
// received every push addressed to every other user, instruction
// text included). Phase 4's session targeting is expected to inherit
// this same "addressed traffic is exclusive of watch-matched
// text included). PLAN-2558 S5's session targeting (TASK-2588)
// inherits this same "addressed traffic is exclusive of watch-matched
// delivery" semantics, so it's pinned explicitly here rather than
// left to fall out of the shared TargetUserID field's shape.
if n.Kind == watchevents.KindPush {
return n.TargetUserID != "" && n.TargetUserID == userID
if n.TargetUserID == "" || n.TargetUserID != userID {
return false
}
// TargetSessionID narrows delivery to one of userID's own live
// sessions (empty = every one of them, i.e. broadcast is this
// same check with an always-true second leg — one delivery path,
// not two). Deliberately compared AFTER the TargetUserID check
// above, never before: a session id is meaningless without first
// confirming this connection belongs to the addressed user at
// all, and evaluating it first would invite conflating "wrong
// user" with "wrong session" in some future edit.
return n.TargetSessionID == "" || n.TargetSessionID == sessionID
}
predicate, watched := watches[n.ItemID]
+69 -17
View File
@@ -578,7 +578,7 @@ func TestWatchNotificationVisible_UnconditionalWatch(t *testing.T) {
t.Parallel()
watches := map[string]string{"item-1": ""}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindComment}
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected an unconditional watch to match any notification on the item")
}
}
@@ -587,7 +587,7 @@ func TestWatchNotificationVisible_UnwatchedItemDenied(t *testing.T) {
t.Parallel()
watches := map[string]string{"item-1": ""}
n := watchevents.Notification{ItemID: "item-2", Kind: watchevents.KindComment}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected an unwatched item's notification to be denied")
}
}
@@ -596,7 +596,7 @@ func TestWatchNotificationVisible_PredicateGatesNonStatusKinds(t *testing.T) {
t.Parallel()
watches := map[string]string{"item-1": "status=done"}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindComment}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected a predicated watch to suppress a comment notification")
}
}
@@ -606,10 +606,10 @@ func TestWatchNotificationVisible_PredicateMatchesOnlyTargetValue(t *testing.T)
watches := map[string]string{"item-1": "status=done"}
wrong := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindStatusChange, StatusFieldKey: "status", ToStatus: "in-progress"}
right := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindStatusChange, StatusFieldKey: "status", ToStatus: "done"}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", wrong) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", wrong) {
t.Fatal("expected the non-matching status transition to be denied")
}
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", right) {
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", right) {
t.Fatal("expected the matching status transition to be visible")
}
}
@@ -624,10 +624,10 @@ func TestWatchNotificationVisible_PredicateMatchesOnlyTargetValue(t *testing.T)
func TestWatchNotificationVisible_AssignmentToYouNeedsAWatch(t *testing.T) {
t.Parallel()
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindAssignment, AssignedUserID: "user-1"}
if watchNotificationVisible(map[string]string{}, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(map[string]string{}, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected an assignment-to-you notification to be denied with no watch on the item")
}
if !watchNotificationVisible(map[string]string{"item-1": ""}, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if !watchNotificationVisible(map[string]string{"item-1": ""}, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected the same assignment to be visible to a caller holding an unconditional watch")
}
}
@@ -636,7 +636,7 @@ func TestWatchNotificationVisible_AssignmentToSomeoneElseDenied(t *testing.T) {
t.Parallel()
watches := map[string]string{}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindAssignment, AssignedUserID: "user-2"}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected an assignment to someone else to be denied")
}
}
@@ -655,7 +655,7 @@ func TestWatchNotificationVisible_AssignmentToSomeoneElseVisibleToWatcher(t *tes
t.Parallel()
watches := map[string]string{"item-1": ""} // unconditional watch
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindAssignment, AssignedUserID: "user-2"}
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected an unconditional watcher to see an assignment to someone else — an assignment is an item-level fact, not private dispatch")
}
}
@@ -668,11 +668,63 @@ func TestWatchNotificationVisible_PushToYou(t *testing.T) {
t.Parallel()
watches := map[string]string{} // no watches at all
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindPush, TargetUserID: "user-1"}
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected a push-to-you notification to be visible with no watch")
}
}
// TestWatchNotificationVisible_PushSessionTargetedMatchDelivers is
// PLAN-2558 S5's (TASK-2588) core positive case: a push naming a
// specific session id is visible on the connection whose OWN registry
// id matches it, even though TargetUserID alone would already have
// allowed it — this pins that the narrower predicate doesn't accidentally
// deny a matching session.
func TestWatchNotificationVisible_PushSessionTargetedMatchDelivers(t *testing.T) {
t.Parallel()
watches := map[string]string{}
n := watchevents.Notification{
ItemID: "item-1", Kind: watchevents.KindPush,
TargetUserID: "user-1", TargetSessionID: "sess-a",
}
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "sess-a", n) {
t.Fatal("expected a session-targeted push to be visible on the matching session")
}
}
// TestWatchNotificationVisible_PushSessionTargetedMismatchDenied is S5's
// core negative case: TargetUserID matching is NOT sufficient once
// TargetSessionID is set — a push aimed at one of the user's sessions
// must not leak to another of that SAME user's own connected sessions.
func TestWatchNotificationVisible_PushSessionTargetedMismatchDenied(t *testing.T) {
t.Parallel()
watches := map[string]string{}
n := watchevents.Notification{
ItemID: "item-1", Kind: watchevents.KindPush,
TargetUserID: "user-1", TargetSessionID: "sess-a",
}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "sess-b", n) {
t.Fatal("expected a session-targeted push to be denied on a different session of the same user")
}
}
// TestWatchNotificationVisible_PushEmptySessionTargetMatchesAnySession
// pins that an untargeted (broadcast) push is unaffected by S5: an
// empty TargetSessionID must still reach every one of the user's own
// sessions regardless of that connection's own registry id — including
// the zero-value id a connection gets when there is no presence
// registry wired at all (see the sessionID hoist in
// handleWatchEventsStream).
func TestWatchNotificationVisible_PushEmptySessionTargetMatchesAnySession(t *testing.T) {
t.Parallel()
watches := map[string]string{}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindPush, TargetUserID: "user-1"}
for _, sessionID := range []string{"sess-a", "sess-b", ""} {
if !watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", sessionID, n) {
t.Fatalf("expected a broadcast push (empty TargetSessionID) to be visible regardless of this connection's session id %q", sessionID)
}
}
}
// TestWatchNotificationVisible_PushToSomeoneElseDenied mirrors
// TestWatchNotificationVisible_AssignmentToSomeoneElseDenied for
// KindPush: Phase 1 only ever publishes self-addressed pushes, but the
@@ -682,7 +734,7 @@ func TestWatchNotificationVisible_PushToSomeoneElseDenied(t *testing.T) {
t.Parallel()
watches := map[string]string{}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindPush, TargetUserID: "user-2"}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected a push addressed to someone else to be denied")
}
}
@@ -703,7 +755,7 @@ func TestWatchNotificationVisible_PushToSomeoneElseDeniedEvenWithUnconditionalWa
t.Parallel()
watches := map[string]string{"item-1": ""} // unconditional watch on the item
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindPush, TargetUserID: "user-2"}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected a push addressed to someone else to be denied even when the caller holds an unconditional watch on the item")
}
}
@@ -715,7 +767,7 @@ func TestWatchNotificationVisible_PushToSomeoneElseDeniedEvenWithPredicateWatch(
t.Parallel()
watches := map[string]string{"item-1": "status=done"}
n := watchevents.Notification{ItemID: "item-1", Kind: watchevents.KindPush, TargetUserID: "user-2"}
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", n) {
if watchNotificationVisible(watches, watchAccessVisibility{fullAccess: true}, "user-1", "", n) {
t.Fatal("expected a push addressed to someone else to be denied even when the caller holds a predicated watch on the item")
}
}
@@ -732,12 +784,12 @@ func TestWatchNotificationVisible_PushStillGatedByAccess(t *testing.T) {
ItemID: "item-1", CollectionID: "coll-1",
Kind: watchevents.KindPush, TargetUserID: "user-1",
}
if watchNotificationVisible(watches, deny, "user-1", n) {
if watchNotificationVisible(watches, deny, "user-1", "", n) {
t.Fatal("expected a push-to-you notification to be denied when the caller has no current access to the item's collection")
}
allow := watchAccessVisibility{visibleCollIDs: map[string]bool{"coll-1": true}}
if !watchNotificationVisible(watches, allow, "user-1", n) {
if !watchNotificationVisible(watches, allow, "user-1", "", n) {
t.Fatal("expected a push-to-you notification to be visible once the caller has current access to the item's collection")
}
}
@@ -767,12 +819,12 @@ func TestWatchNotificationVisible_AssignmentStillGatedByAccess(t *testing.T) {
ItemID: "item-1", CollectionID: "coll-1",
Kind: watchevents.KindAssignment, AssignedUserID: "user-1",
}
if watchNotificationVisible(watches, deny, "user-1", n) {
if watchNotificationVisible(watches, deny, "user-1", "", n) {
t.Fatal("expected a watch-matched assignment notification to be denied when the caller has no current access to the item's collection")
}
allow := watchAccessVisibility{visibleCollIDs: map[string]bool{"coll-1": true}}
if !watchNotificationVisible(watches, allow, "user-1", n) {
if !watchNotificationVisible(watches, allow, "user-1", "", n) {
t.Fatal("expected the same notification to be visible once the collection is in the caller's current access")
}
}
+27 -7
View File
@@ -43,13 +43,18 @@ const (
// eventually exists.
KindAsk = "ask"
// KindPush is IDEA-2544 Phase 1's human→harness addressed-dispatch
// event: POST .../items/{itemSlug}/push publishes exactly one of
// these, self-addressed (TargetUserID == the pushing user), any time
// a user wants to put an item + instruction in front of their own
// harness right now rather than waiting on assignment/watch
// semantics. KindAsk is push's reserved sibling in the other
// direction (harness→human) — see TargetUserID's doc comment for the
// shared envelope shape the two are meant to converge on.
// event: POST .../items/{itemSlug}/push accepts one of these,
// self-addressed (TargetUserID == the pushing user), any time a user
// wants to put an item + instruction in front of their own harness
// right now rather than waiting on assignment/watch semantics. NOT
// "publishes exactly one" unconditionally as of PLAN-2558 S5
// (TASK-2588): handlePushToItem decides whether to publish at all —
// a session-targeted request whose id matches no live session skips
// the publish entirely (a guaranteed no-op; see TargetSessionID and
// pushResponse.DeliveredSessions' doc comments in handlers_push.go).
// KindAsk is push's reserved sibling in the other direction
// (harness→human) — see TargetUserID's doc comment for the shared
// envelope shape the two are meant to converge on.
KindPush = "push"
)
@@ -104,6 +109,21 @@ type Notification struct {
// notification could have been addressed to, and echoing it back
// would leak a user ID for no consumer that needs it.
TargetUserID string
// TargetSessionID narrows TargetUserID to one of that user's live
// event-stream connections (PLAN-2558 S5, TASK-2588). Populated only
// on Kind == KindPush, and only when the pusher named a specific
// session id from the S1 presence registry (GET /api/v1/sessions);
// empty means "every one of TargetUserID's connected sessions" —
// broadcast is targeted-with-an-empty-predicate, not a separate
// code path. watchNotificationVisible checks this against the
// SAME session id session_presence.go handed the connection at
// Add() time, exactly parallel to how TargetUserID gates against
// the connected caller's user id. An id that names no live session
// (vanished, mistyped, or — deliberately indistinguishable from
// either — belonging to a DIFFERENT user) simply matches nothing:
// there is no separate "not found" signal here, by design, because
// this field must never become an existence oracle across users.
TargetSessionID string
// StatusFieldKey / ToStatus are populated on Kind == KindStatusChange,
// mirroring models.ItemMutationSignal, so the `--until field=value`
// watch predicate can be evaluated against a Notification directly
+29 -11
View File
@@ -1423,27 +1423,45 @@ export const api = {
/**
* Push an instruction about this item to the caller's OWN connected
* agent sessions (IDEA-2544 Phase 1 / PLAN-2558 S3).
* agent sessions (IDEA-2544 Phase 1 / PLAN-2558 S3), or to exactly
* one of them when `targetSessionId` is given (PLAN-2558 S5,
* TASK-2588 — an id from `api.sessions.list()`).
*
* Fire-and-forget: there is no durable inbox and no ack, so a
* resolved promise means "published to the bus", never "an agent read
* it". Pair the call site with `api.sessions.list()` so the user
* knows whether anything is listening BEFORE they click — that pairing
* is the entire reason this endpoint has a web surface.
* resolved promise means "accepted and processed" (`pushed: true` in
* ItemPushResult), never "an agent read it" — and, as of PLAN-2558
* S5, not even "published to the bus" unconditionally: a targeted
* request whose `targetSessionId` matches no live session resolves
* with `delivered_sessions: 0` and the server skips the publish
* entirely (see ItemPushResult's doc comment). Pair the call site
* with `api.sessions.list()` so the user knows whether anything is
* listening BEFORE they click — that pairing is the entire reason
* this endpoint has a web surface.
*
* Self-addressed only; there is no target parameter, by design (see
* handlePushToItem). `message` is collapsed and bounded server-side —
* use `$lib/push/message` to apply the same rules in the composer so
* Self-addressed only; there is no cross-user target, by design (see
* handlePushToItem) — `targetSessionId` can only narrow delivery
* within the caller's OWN sessions, never address anyone else's.
* `message` is collapsed and bounded server-side — use
* `$lib/push/message` to apply the same rules in the composer so
* over-length text is caught before it becomes a 400.
*
* Omitting `targetSessionId` sends the exact pre-S5 body shape
* (`{ message }`, no `target_session_id` key at all) — existing
* broadcast call sites (QuickActionsMenu's S4 dispatch) are
* unaffected by this parameter's addition.
*
* NEVER auto-retry this. Like the copy endpoint it carries no
* idempotency key, so a retry after an ambiguous failure can deliver
* the same instruction twice.
* the same instruction twice. A targeted miss (`delivered_sessions
* === 0` in the response) is the one exception a caller may act on
* safely — see ItemPushResult's doc comment.
*/
push: (ws: string, itemSlug: string, message: string) =>
push: (ws: string, itemSlug: string, message: string, targetSessionId?: string) =>
request<ItemPushResult>(`/workspaces/${ws}/items/${itemSlug}/push`, {
method: 'POST',
body: JSON.stringify({ message })
body: JSON.stringify(
targetSessionId ? { message, target_session_id: targetSessionId } : { message }
)
})
},
@@ -1,7 +1,19 @@
<!--
@component
PushToAgentDialog — compose an instruction about an item and push it to the
user's own connected agent sessions (PLAN-2558 S3, IDEA-2544 Phase 3).
user's own connected agent sessions (PLAN-2558 S3, IDEA-2544 Phase 3), or to
one specific session (PLAN-2558 S5, TASK-2588) via the target picker below
the presence line.
THE TARGET PICKER REUSES THE PRESENCE READ, IT DOESN'T ADD ONE. `sessions`
below is already fetched for the presence line's own count; the picker's
options are that same array, so there is no second `GET /api/v1/sessions`
and the two surfaces can never disagree about who's connected. A targeted
send whose `delivered_sessions` comes back 0 (the addressed session vanished
between the last poll and the click — the same staleness window the presence
line already carries) toasts and re-polls rather than closing: zero delivery
means nothing was sent, so nothing is duplicated by trying again against a
freshly-read list.
Built on the shared `Modal` primitive, same as CopyItemDialog: the composer
needs more room than the pane menu's drill-down affords, and the native
@@ -91,6 +103,7 @@ server tells you not to run would cost honesty in the case that actually ships.
const textareaId = `push-dialog-message-${uid}`;
const counterId = `push-dialog-counter-${uid}`;
const noteId = `push-dialog-note-${uid}`;
const targetId = `push-dialog-target-${uid}`;
/**
* Presence re-read cadence while the dialog is open. A session can connect
@@ -137,6 +150,14 @@ server tells you not to run would cost honesty in the case that actually ships.
let presenceReason = $state('');
let message = $state('');
/**
* The chosen target, or '' for broadcast (PLAN-2558 S5, TASK-2588).
* Populated from `sessions` (the same presence read the count above
* uses — no separate fetch), so it degrades exactly the way the
* count does: an id that drops out of `sessions` on the next poll
* simply stops being an option, same as any other session leaving.
*/
let selectedSessionId = $state('');
let sending = $state(false);
let sendError = $state('');
/**
@@ -219,6 +240,29 @@ server tells you not to run would cost honesty in the case that actually ships.
presenceState !== 'checking'
);
/**
* Keep `selectedSessionId` valid whenever `sessions` changes. Called
* right after every `sessions = ...` reassignment that ISN'T already
* paired with an explicit reset of the selection (TASK-2588 round 2,
* codex).
*
* A `<select>` whose bound value names an `<option>` that no longer
* exists typically falls back to DISPLAYING the first remaining
* option (here, "All connected sessions") while the underlying bound
* value stays the stale id — so the user visually sees "broadcast"
* selected while the wire would still carry the dead target_session_id
* on send. Reconciling here (not via a $effect that reads
* `selectedSessionId`, which would read the same state it writes —
* CONVE-1688) closes that at every point `sessions` can change: a
* live poll dropping the selected session, and the staleness-expiry
* path below that clears `sessions` directly.
*/
function reconcileSelectedSession() {
if (selectedSessionId && !sessions.some((s) => s.id === selectedSessionId)) {
selectedSessionId = '';
}
}
async function refreshPresence(gen: number): Promise<void> {
const seq = ++presenceSeq;
/**
@@ -242,6 +286,7 @@ server tells you not to run would cost honesty in the case that actually ships.
presenceAppliedSeq = seq;
lastAnsweredAt = Date.now();
sessions = resp.sessions ?? [];
reconcileSelectedSession();
presenceState = 'known';
presenceReason = '';
} catch (err) {
@@ -251,6 +296,7 @@ server tells you not to run would cost honesty in the case that actually ships.
// distinguishes the one case a self-hosted user can act on (the
// server has no presence registry) from a transient read failure.
sessions = [];
reconcileSelectedSession();
presenceState = 'unknown';
presenceReason =
err instanceof PadApiError && err.code === 'unavailable'
@@ -272,6 +318,7 @@ server tells you not to run would cost honesty in the case that actually ships.
presenceGen += 1;
presenceAppliedSeq = 0;
message = defaultPushMessage(itemRef, itemTitle);
selectedSessionId = '';
sending = false;
sendError = '';
outcomeUnknown = false;
@@ -291,6 +338,7 @@ server tells you not to run would cost honesty in the case that actually ships.
// more authority than "can't tell", so we say that instead.
if (presenceState === 'known' && Date.now() - lastAnsweredAt > PRESENCE_MAX_AGE_MS) {
sessions = [];
reconcileSelectedSession();
presenceState = 'unknown';
presenceReason = 'The last check was a while ago and hasnt refreshed.';
// Retire every request already in flight (codex round 3). Those
@@ -333,13 +381,60 @@ server tells you not to run would cost honesty in the case that actually ships.
// and only liveness distinguishes A's stale continuation from B's.
const gen = presenceGen;
const stillMine = () => !destroyed && gen === presenceGen;
// Captured before the await, same as every other read of reactive
// state in this function — `selectedSessionId` can't actually change
// while `sending` disables the picker, but the pattern is load-bearing
// elsewhere in this file and cheap to keep consistent here too.
const target = selectedSessionId;
sending = true;
sendError = '';
try {
// NEVER retried automatically, at this call site or any other: the
// endpoint carries no idempotency key.
await api.items.push(wsSlug, itemSlug, collapsed);
// endpoint carries no idempotency key. Broadcast keeps the exact
// pre-S5 3-argument call — only a non-empty target adds the 4th.
const result = target
? await api.items.push(wsSlug, itemSlug, collapsed, target)
: await api.items.push(wsSlug, itemSlug, collapsed);
sending = false;
if (target && result.delivered_sessions === undefined) {
// Mixed-version hazard (TASK-2588 round 2, codex). The server
// ships EMBEDDED in the binary (web/build is baked into the Go
// build), so a version skew between this tab's JS and the
// server it's talking to can only exist transiently — a stale
// tab surviving a server swap — never as a sustained topology.
// That's still worth a cheap check, not a capability-
// negotiation system: a response to a targeted send with no
// delivered_sessions AT ALL means the server that answered
// doesn't know about targeting (pre-S5, or a proxy that
// stripped the field) — server-side, that server unconditionally
// PUBLISHES every push it accepts (the pre-S5 contract), so this
// was NOT skipped the way a same-version miss is. Tell the user
// honestly rather than either silently treating it as delivered
// or, worse, running the miss-flow against a `0` that was never
// actually reported — this branch must run BEFORE the `=== 0`
// check below, and must never fall through to it.
toastStore.show('server didnt confirm targeting — sent as broadcast', 'info');
if (!stillMine()) return;
handleDismiss();
return;
}
if (target && result.delivered_sessions === 0) {
// A targeted push that reached nobody. As of TASK-2588 round 1
// the server SKIPS the publish entirely for this case (see
// pushResponse.DeliveredSessions' doc comment) — nothing was
// sent, so zero delivery is a guarantee, not a race, and
// nothing would be duplicated by resending. Unlike every other
// outcome here, it is therefore safe to leave Push re-armed
// rather than closing: drop the stale selection back to
// broadcast and re-read presence so the picker reflects what
// is actually still connected, then let the user resend to a
// live target.
toastStore.show('that session is gone — refresh the list', 'error');
if (!stillMine()) return;
selectedSessionId = '';
void refreshPresence(gen);
return;
}
// The toast fires even if this instance is gone — the push really
// happened, and suppressing the confirmation would be the dishonest
// half. Honest past tense: the notification was PUBLISHED. Whether an
@@ -456,6 +551,26 @@ server tells you not to run would cost honesty in the case that actually ships.
{/if}
</section>
<!-- ── Target ────────────────────────────────────────────────────────
Only rendered once there is something to pick between (PLAN-2558
S5, TASK-2588) — with zero sessions Send is already disabled by
`noListeners`, so a picker here would offer a choice that can't be
acted on. Broadcast is the default on every fresh open (Fresh-on-
open reset above), never a remembered previous target. -->
{#if sessionCount > 0}
<section class="section">
<label class="field-label" for={targetId}>Send to</label>
<select id={targetId} class="target-picker" bind:value={selectedSessionId} disabled={sending}>
<option value=""
>All connected sessions ({sessionCount})</option
>
{#each sessions as session (session.id)}
<option value={session.id}>{sessionName(session)}</option>
{/each}
</select>
</section>
{/if}
<!-- ── Message ───────────────────────────────────────────────────── -->
<section class="section">
<label class="field-label" for={textareaId}>Message</label>
@@ -594,6 +709,12 @@ server tells you not to run would cost honesty in the case that actually ships.
/* Border / background / padding come from app.css's global
`input, textarea, select` rule — only the box behaviour is local. */
/* Border / background / padding come from app.css's global
`input, textarea, select` rule, same as .composer above. */
.target-picker {
width: 100%;
box-sizing: border-box;
}
.composer {
width: 100%;
box-sizing: border-box;
@@ -95,6 +95,12 @@ function textarea(): HTMLTextAreaElement {
return el as HTMLTextAreaElement;
}
/** The session-target picker (PLAN-2558 S5), or null when the presence
* state has no live sessions to pick between (it isn't rendered at all). */
function targetPicker(): HTMLSelectElement | null {
return document.querySelector('select');
}
/** Mount, then let the initial presence read settle. */
async function mountSettled(props: Record<string, unknown> = {}) {
const result = render(PushToAgentDialog, { props: baseProps(props) });
@@ -112,7 +118,8 @@ beforeEach(() => {
ref: 'TASK-5',
workspace: 'docapp',
pushed: true,
message: 'ok'
message: 'ok',
delivered_sessions: 1
});
sessionsListMock.mockReset().mockResolvedValue(sessions(1));
toastMock.mockReset();
@@ -504,3 +511,229 @@ describe('PushToAgentDialog — message handling', () => {
expect(pushMock).toHaveBeenCalledTimes(1);
});
});
describe('PushToAgentDialog — session targeting (PLAN-2558 S5, TASK-2588)', () => {
it('renders a broadcast default plus one option per connected session', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
await mountSettled();
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker with 2 live sessions');
const options = Array.from(picker.options).map((o) => ({ value: o.value, text: o.textContent }));
expect(options[0]).toEqual({ value: '', text: expect.stringContaining('All connected sessions (2)') });
expect(options[1]).toEqual({ value: 'session-id-0', text: 'docapp-0 (pid 1000)' });
expect(options[2]).toEqual({ value: 'session-id-1', text: 'docapp-1 (pid 1001)' });
});
it('does not render a picker with zero sessions — nothing to pick between', async () => {
sessionsListMock.mockResolvedValue(sessions(0));
await mountSettled();
expect(targetPicker()).toBeNull();
});
it('defaults to broadcast: an untouched send keeps the exact pre-S5 3-argument call', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
await mountSettled();
await fireEvent.click(button('Push'));
await tick();
// No 4th argument at all — not even an explicit `undefined` — so this
// stays byte-identical to every pre-S5 broadcast call site
// (QuickActionsMenu's S4 dispatch included).
expect(pushMock).toHaveBeenCalledWith(
'docapp',
'fix-the-thing',
'Take a look at TASK-5 — Fix the thing'
);
});
it('selecting a session passes its id as the 4th push argument', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
await mountSettled();
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-1' } });
await fireEvent.click(button('Push'));
await tick();
expect(pushMock).toHaveBeenCalledWith(
'docapp',
'fix-the-thing',
'Take a look at TASK-5 — Fix the thing',
'session-id-1'
);
});
it('a targeted miss (delivered_sessions=0) toasts, keeps the dialog open, drops back to broadcast, and re-reads presence — because zero delivery means nothing to duplicate by resending', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
pushMock.mockResolvedValueOnce({
ref: 'TASK-5',
workspace: 'docapp',
pushed: true,
message: 'ok',
delivered_sessions: 0
});
const onclose = vi.fn();
await mountSettled({ onclose });
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-0' } });
const presenceReadsBeforeSend = sessionsListMock.mock.calls.length;
await fireEvent.click(button('Push'));
await tick();
await tick();
expect(toastMock).toHaveBeenCalledWith('that session is gone — refresh the list', 'error');
// Still open — unlike every OTHER successful-response outcome, a
// definitive zero-delivery result is safe to leave re-armed rather
// than closing, since nothing was actually sent to duplicate.
expect(onclose).not.toHaveBeenCalled();
// The stale selection drops back to broadcast, and presence is
// re-read so the picker reflects who's actually still connected.
expect(targetPicker()?.value).toBe('');
expect(sessionsListMock.mock.calls.length).toBeGreaterThan(presenceReadsBeforeSend);
});
it('a successful targeted push (delivered_sessions > 0) closes normally, same as broadcast', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
pushMock.mockResolvedValueOnce({
ref: 'TASK-5',
workspace: 'docapp',
pushed: true,
message: 'ok',
delivered_sessions: 1
});
const onclose = vi.fn();
await mountSettled({ onclose });
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-0' } });
await fireEvent.click(button('Push'));
await tick();
await tick();
expect(onclose).toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledTimes(1);
const [msg] = toastMock.mock.calls[0] ?? [];
expect(String(msg)).toMatch(/isnt confirmed|is not confirmed/);
});
it('a refresh that removes the selected session drops the picker back to broadcast — the next send carries no target_session_id', async () => {
vi.useFakeTimers();
sessionsListMock.mockResolvedValue(sessions(2));
render(PushToAgentDialog, { props: baseProps() });
await vi.advanceTimersByTimeAsync(0);
flushSync();
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-0' } });
expect(targetPicker()?.value).toBe('session-id-0');
// The next poll's list no longer has session-id-0 — the OTHER
// session is still there, so this is a picker-visible refresh, not
// a drop to zero (which is already covered by presence-honesty's
// "session drops mid-compose" test).
sessionsListMock.mockResolvedValue({
count: 1,
sessions: [
{
id: 'session-id-1',
label: 'docapp-1',
pid: 1001,
connected_at: new Date(Date.now() - 60_000).toISOString()
}
]
});
await vi.advanceTimersByTimeAsync(10_000);
flushSync();
// A <select> whose selected <option> vanished falls back to
// DISPLAYING the remaining default while the bound value can stay
// stale — this asserts the bound value itself, not just the
// rendered option list.
expect(targetPicker()?.value).toBe('');
await fireEvent.click(button('Push'));
await tick();
// The pre-S5 3-argument shape — no target_session_id at all, not
// even the now-dead 'session-id-0'.
expect(pushMock).toHaveBeenCalledWith(
'docapp',
'fix-the-thing',
'Take a look at TASK-5 — Fix the thing'
);
});
it('a refresh that keeps the selected session live does not clobber the selection', async () => {
vi.useFakeTimers();
sessionsListMock.mockResolvedValue(sessions(2));
render(PushToAgentDialog, { props: baseProps() });
await vi.advanceTimersByTimeAsync(0);
flushSync();
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-0' } });
// Same two sessions again — session-id-0 is still present.
sessionsListMock.mockResolvedValue(sessions(2));
await vi.advanceTimersByTimeAsync(10_000);
flushSync();
expect(targetPicker()?.value).toBe('session-id-0');
await fireEvent.click(button('Push'));
await tick();
expect(pushMock).toHaveBeenCalledWith(
'docapp',
'fix-the-thing',
'Take a look at TASK-5 — Fix the thing',
'session-id-0'
);
});
it('a targeted send whose response omits delivered_sessions entirely (mixed-version server) shows an info toast and closes — never the miss-flow', async () => {
sessionsListMock.mockResolvedValue(sessions(2));
// A pre-S5 server's response shape: no delivered_sessions key at
// all, not even 0. A server that doesn't know about targeting
// still unconditionally publishes every push it accepts (the
// pre-S5 contract), so this is NOT the same as a same-version 0.
pushMock.mockResolvedValueOnce({
ref: 'TASK-5',
workspace: 'docapp',
pushed: true,
message: 'ok'
});
const onclose = vi.fn();
await mountSettled({ onclose });
const picker = targetPicker();
if (!picker) throw new Error('expected a target picker');
await fireEvent.change(picker, { target: { value: 'session-id-0' } });
await fireEvent.click(button('Push'));
await tick();
await tick();
expect(toastMock).toHaveBeenCalledWith(
'server didnt confirm targeting — sent as broadcast',
'info'
);
// Never the miss-toast — an absent field is UNKNOWN, not a
// confirmed 0, and must never be treated as one.
expect(toastMock).not.toHaveBeenCalledWith(
'that session is gone — refresh the list',
expect.anything()
);
// Dismissed like a normal success, not re-armed like a miss —
// "no auto-resend enablement" (dispatcher round 2).
expect(onclose).toHaveBeenCalled();
});
});
+34 -5
View File
@@ -1825,21 +1825,50 @@ export interface LiveSessionsResponse {
}
/**
* Result of POST /workspaces/{ws}/items/{slug}/push (IDEA-2544 Phase 1).
* Result of POST /workspaces/{ws}/items/{slug}/push (IDEA-2544 Phase 1 /
* PLAN-2558 S5, TASK-2588).
*
* `pushed: true` means the notification was PUBLISHED to the event bus — not
* that any session received it. A push has no durable backing and no ack, so
* there is nothing here that could report delivery, and callers must not
* present this as confirmation that an agent saw it.
* `pushed: true` means the request was ACCEPTED AND PROCESSED, not
* "delivered" (dispatcher ruling, TASK-2588 round 2) — it is true even for
* a targeted push whose id matched no live session, exactly the same shape
* broadcast-with-no-listeners has always returned. There is no separate
* error channel for a targeted miss; `delivered_sessions` is the delivery
* signal, `pushed` is not one and callers must not read it as one.
*
* `message` echoes the server's whitespace-collapsed form, which is what
* actually went on the wire — it may differ from what the user typed.
*
* `delivered_sessions` counts how many of the caller's own live sessions
* (the S1 presence registry, GET /api/v1/sessions — narrowed by
* `target_session_id` if the request set one) matched. It is a PREDICTION
* snapshotted from the same registry the picker itself reads, taken BEFORE
* the notification is published — not a delivery receipt: it carries the
* registry's own staleness window (up to ~30s behind an ungracefully-
* dropped connection — see `LiveSession`) and there is still no ack from
* the receiving side. Callers must not present a nonzero count as
* confirmed delivery; the 0-vs-nonzero distinction is what's load-bearing
* — a targeted push with `delivered_sessions === 0` is a GUARANTEE it
* reached nobody (the server skips the publish entirely in that case, so
* there is nothing to duplicate by resending), never a race.
*
* OPTIONAL, not always present on the wire (codex round 2 mixed-version
* hazard): the server ships embedded in the binary, so a version skew
* between the JS a tab is running and the server it talks to can only
* exist transiently — a stale browser tab surviving a server swap — never
* as a real, sustained multi-version topology. That's still real enough
* to check for: a caller that sent `target_session_id` and gets back a
* response with no `delivered_sessions` at all is talking to a server
* that doesn't know about targeting yet (or a proxy that stripped it).
* Treat that as UNKNOWN, never as a confirmed miss — `=== 0` must be
* checked explicitly, never inferred from `!delivered_sessions` or a
* falsy check that would also match `undefined`.
*/
export interface ItemPushResult {
ref: string;
workspace: string;
pushed: boolean;
message: string;
delivered_sessions?: number;
}
/**