From 2b0ac5cbaeb7e24f72181b44ebfa73e325f2ad81 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 30 Jul 2026 10:59:58 -0500 Subject: [PATCH 01/25] groups: add group-level custom blob field Add an opaque, admin-writable blob=(unit @t) to $group for client-defined metadata, mirroring the channel-level custom meta lane. New v11 types carry %blob on a-group/c-group/u-group/r-group; responses ride a new /v3/groups lane (group-response-3) with conv downgraders keeping v2/v1/v0 blob-free. Actions ride a new strict group-action-5 mark; group-action-4 stays frozen at v8. State bumps %11 -> %12 (blob defaults to ~, pub logs re-walked) and the groups negotiate version bumps %2 -> %3 since the update/log/command wire shapes change. New read surfaces: /x/v3 group scries, /x/v4/init, /v9/init and /v10/changes on %groups-ui. Co-Authored-By: Claude Fable 5 --- desk/app/channels-server.hoon | 2 +- desk/app/channels.hoon | 2 +- desk/app/groups-ui.hoon | 32 ++++ desk/app/groups.hoon | 215 ++++++++++++++++++++------- desk/lib/groups-conv.hoon | 195 ++++++++++++++++++++++++ desk/lib/groups-json.hoon | 108 ++++++++++++++ desk/lib/mark-warmer.hoon | 5 + desk/lib/rail.hoon | 35 +++++ desk/mar/group-3.hoon | 14 ++ desk/mar/group-ui-3.hoon | 14 ++ desk/mar/group/action-5.hoon | 14 ++ desk/mar/group/changed-groups-3.hoon | 20 +++ desk/mar/group/response-3.hoon | 14 ++ desk/mar/groups-3.hoon | 14 ++ desk/mar/ui/init-9.hoon | 38 +++++ desk/sur/groups-ver.hoon | 140 +++++++++++++++++ desk/sur/groups.hoon | 7 + desk/sur/ui.hoon | 9 ++ desk/tests/app/groups-server.hoon | 84 ++++++++--- desk/tests/app/groups.hoon | 107 ++++++++----- 20 files changed, 957 insertions(+), 112 deletions(-) create mode 100644 desk/mar/group-3.hoon create mode 100644 desk/mar/group-ui-3.hoon create mode 100644 desk/mar/group/action-5.hoon create mode 100644 desk/mar/group/changed-groups-3.hoon create mode 100644 desk/mar/group/response-3.hoon create mode 100644 desk/mar/groups-3.hoon create mode 100644 desk/mar/ui/init-9.hoon diff --git a/desk/app/channels-server.hoon b/desk/app/channels-server.hoon index 7633943d2a..a433e5ec3f 100644 --- a/desk/app/channels-server.hoon +++ b/desk/app/channels-server.hoon @@ -34,7 +34,7 @@ %- %- agent:neg :+ notify=| [~.channels^%4 ~ ~] - (my %groups^[~.groups^%2 ~ ~] ~) + (my %groups^[~.groups^%3 ~ ~] ~) %- agent:dbug %^ verb | %warn :: diff --git a/desk/app/channels.hoon b/desk/app/channels.hoon index 1403430ff8..2a8b2a61db 100644 --- a/desk/app/channels.hoon +++ b/desk/app/channels.hoon @@ -160,7 +160,7 @@ :+ notify=& [~.channels^%4 ~ ~] %- my - :~ %groups^[~.groups^%2 ~ ~] + :~ %groups^[~.groups^%3 ~ ~] %channels-server^[~.channels^%4 ~ ~] == %- agent:dbug diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon index b24778b3e8..10fa74ebcc 100644 --- a/desk/app/groups-ui.hoon +++ b/desk/app/groups-ui.hoon @@ -236,6 +236,23 @@ profile == ``ui-init-8+!>(init) + :: + [%x %v9 %init ~] + =+ .^([=groups-ui:v11:gv =foreigns:v8:gv] (scry %gx %groups /v4/init/noun)) + =+ .^(channel=channel-10:u (scry %gx %channels /v6/init/noun)) + =+ .^(chat=chat-2:u (scry %gx %chat /v1/init/noun)) + =+ .^(=activity:v8:av (scry %gx %activity /v4/activity/noun)) + =+ .^(profile=? (scry %gx %profile /bound/loob)) + =/ init=init-9:u + :* groups-ui + foreigns + channel + activity + pins + chat + profile + == + ``ui-init-9+!>(init) :: [%x %v5 %changes since=@ ~] =+ .^(activity=json (scry %gx %activity /v4/activity/changes/[since.pole]/json)) @@ -311,6 +328,21 @@ 'groups'^groups 'contacts'^contacts == + :: + [%x %v10 %changes since=@ ~] + =+ .^(activity=json (scry %gx %activity /v4/activity/changes/[since.pole]/json)) + =+ .^(channels=json (scry %gx %channels /v6/changes/[since.pole]/json)) + =+ .^(chat=json (scry %gx %chat /v4/changes/[since.pole]/json)) + =+ .^(groups=json (scry %gx %groups /v3/changes/[since.pole]/json)) + =+ .^(contacts=json (scry %gx %contacts /v2/changes/[since.pole]/json)) + :^ ~ ~ %json + !> %- pairs:enjs:format + :~ 'activity'^activity + 'channels'^channels + 'chat'^chat + 'groups'^groups + 'contacts'^contacts + == :: [%x %v5 %init-posts channels=@ context=@ ~] =+ .^(channels=json (scry %gx %channels /v5/init-posts/[channels.pole]/[context.pole]/json)) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 87db36a34d..ea483f395c 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -27,14 +27,18 @@ :~ %group %group-1 %group-2 + %group-3 %groups %groups-1 %groups-2 + %groups-3 %groups-ui %groups-ui-1 %groups-ui-2 + %group-ui-3 %group-changed-groups-1 %group-changed-groups-2 + %group-changed-groups-3 %group-preview %group-preview %group-preview-3 @@ -45,7 +49,9 @@ %channel-preview-1 %group-response-1 %group-response-2 + %group-response-3 %group-action-3 + %group-action-5 %group-channel-active %group-channel-join %group-channel-leave @@ -63,6 +69,7 @@ :: [/v1/groups %group-response-1 ~] [/v2/groups %group-response-2 ~] + [/v3/groups %group-response-3 ~] [/groups/ui %group-action-3 ~] :: [/v1/channels/$/$/$/preview %channel-preview-1 ~] @@ -80,13 +87,16 @@ :~ [/x/init/v1 %noun] [/x/v2/init %noun] [/x/v3/init %noun] + [/x/v4/init %noun] :: [/x/v0/groups %groups] [/x/v1/groups %groups-1] [/x/v2/groups %groups-2] + [/x/v3/groups %groups-3] :: [/x/v1/changes %group-changed-groups-1] [/x/v2/changes %group-changed-groups-2] + [/x/v3/changes %group-changed-groups-3] :: [/x/v2/groups/$/$/channels/can-read %noun] [/x/v2/groups/$/$/channels/$/$/$/can-write %noun] @@ -106,10 +116,12 @@ [/x/v0/ui/groups/$/$ %group-ui] [/x/v1/ui/groups/$/$ %group-ui-1] [/x/v2/ui/groups/$/$ %group-ui-2] + [/x/v3/ui/groups/$/$ %group-ui-3] :: [/x/v0/groups/$/$ %group] [/x/v1/groups/$/$ %group-1] [/x/v2/groups/$/$ %group-2] + [/x/v3/groups/$/$ %group-3] :: [/x/v1/foreigns %foreigns-1] [/x/v1/foreigns/$/$ %foreign-1] @@ -123,9 +135,9 @@ :: %- %- agent:neg :+ notify=& - [~.groups^%2 ~ ~] + [~.groups^%3 ~ ~] %- my - :~ %groups^[~.groups^%2 ~ ~] + :~ %groups^[~.groups^%3 ~ ~] %channels^[~.channels^%4 ~ ~] %channels-server^[~.channels^%4 ~ ~] == @@ -138,8 +150,8 @@ +$ card card:guard +$ rail rail:guard +$ current-state - $: %11 - groups=net-groups:v9:gv + $: %12 + groups=net-groups:v11:gv =channels-index:v7:gv =foreigns:v10:gv leaves=(set flag:gv) @@ -286,7 +298,7 @@ == :: %group-command - =+ !<(=c-groups:v8:gv vase) + =+ !<(=c-groups:v11:gv vase) ?- -.c-groups %create =/ =flag:g [our.bowl name.create-group.c-groups] @@ -335,6 +347,23 @@ =/ group-core (go-abed:go-core flag.a-groups) go-abet:(go-leave:group-core &) == + :: + %group-action-5 + =+ !<(=a-groups:v11:gv vase) + ?> from-self + ?- -.a-groups + %group + =/ group-core (go-abed:go-core flag.a-groups) + go-abet:(go-a-group:group-core a-group.a-groups) + :: + %invite + =/ group-core (go-abed:go-core flag.a-groups) + go-abet:(go-a-invite:group-core [ships a-invite]:a-groups) + :: + %leave + =/ group-core (go-abed:go-core flag.a-groups) + go-abet:(go-leave:group-core &) + == :: :: deprecated %group-action-3 @@ -348,7 +377,7 @@ [%group flag [%delete ~]] $(+< group-command+!>(c-groups)) ?: ?=(%secret -.diff) - =/ =group:v9:gv +:(~(got by groups) flag) + =/ =group:g +:(~(got by groups) flag) ?: p.diff :: enable group secrecy :: @@ -718,7 +747,8 @@ [%pass /load/fix-duplicate-groups %arvo %b %wait now.bowl]~ =? cor !=(~ caz-9-to-10) (emil caz-9-to-10) =? old ?=(%10 -.old) (state-10-to-11 old) - ?> ?=(%11 -.old) + =? old ?=(%11 -.old) (state-11-to-12 old) + ?> ?=(%12 -.old) =. state old :: initialize .active-channels on each reload =. cor @@ -771,7 +801,8 @@ cor :: +$ any-state - $% state-11 + $% state-12 + state-11 state-10 state-9 state-8 @@ -894,7 +925,16 @@ =^subs:s =pimp:imp == - +$ state-11 current-state + +$ state-11 + $: %11 + groups=net-groups:v9:gv + =channels-index:v7:gv + =foreigns:v10:gv + leaves=(set flag:gv) + =^subs:s + =pimp:imp + == + +$ state-12 current-state :: ++ state-0-to-1 |= state-0 @@ -1044,6 +1084,15 @@ subs pimp == + :: + ++ state-11-to-12 + |= =state-11 + ~> %spin.['state-11-to-12'] + ^- state-12 + %= state-11 + - %12 + groups (~(run by groups.state-11) v11:net-group:v9:gc) + == -- :: ++ inflate-io @@ -1102,6 +1151,8 @@ [%v1 %groups ~] ?>(from-self cor) :: [%v2 %groups ~] ?>(from-self cor) + :: + [%v3 %groups ~] ?>(from-self cor) :: [ver=%v1 %channels app=@ ship=@ name=@ %preview ~] =/ ship=@p (slav %p ship.pole) @@ -1192,11 +1243,11 @@ [%x %init %v1 ~] =/ groups-light-ui-2 %- ~(urn by groups) - |= [=flag:g =net:v9:gv =group:v9:gv] - =* light-group (drop-seats:group:v9:gc group our.bowl) + |= [=flag:g =net:g =group:g] + =* light-group (drop-seats:group:v11:gc group our.bowl) =/ =status:neg (read-status:neg bowl [p.flag %groups]) - (group-ui:v2:group:v9:gc status net light-group) + (group-ui:v2:group:v11:gc status net light-group) :: we filter out foreigns which are %done, :: since completed gangs are removed after :: the group join in old groups. @@ -1218,9 +1269,9 @@ [%x %v2 %init ~] =/ groups-light-ui-7=(map flag:v7:gv group-ui:v7:gv) %- ~(urn by groups) - |= [=flag:g =net:v9:gv =group:v9:gv] - =* light-group (drop-seats:group:v9:gc group our.bowl) - =+ (group-ui:v7:group:v9:gc net light-group) + |= [=flag:g =net:g =group:g] + =* light-group (drop-seats:group:v11:gc group our.bowl) + =+ (group-ui:v7:group:v11:gc net light-group) :: restore member count after dropping seats :: -(member-count ~(wyt by seats.group)) @@ -1231,9 +1282,9 @@ [%x %v3 %init ~] =/ groups-light-ui-9=(map flag:v9:gv group-ui:v9:gv) %- ~(urn by groups) - |= [=flag:g =net:v9:gv =group:v9:gv] - =* light-group (drop-seats:group:v9:gc group our.bowl) - =+ (group-ui:group:v9:gc net light-group) + |= [=flag:g =net:g =group:g] + =* light-group (drop-seats:group:v11:gc group our.bowl) + =+ (group-ui:v9:group:v11:gc net light-group) :: restore member count after dropping seats :: -(member-count ~(wyt by seats.group)) @@ -1241,19 +1292,34 @@ (~(run by foreigns) v8:foreign:v10:gc) ``unsafe+noun+!>([groups-light-ui-9 foreigns-8]) :: - [%x ver=?(%v0 %v1 %v2) %groups ~] - =/ groups-9=groups:v9:gv (~(run by groups) tail) + [%x %v4 %init ~] + =/ groups-light-ui-11=(map flag:v11:gv group-ui:v11:gv) + %- ~(urn by groups) + |= [=flag:g =net:g =group:g] + =* light-group (drop-seats:group:v11:gc group our.bowl) + =+ (group-ui:group:v11:gc net light-group) + :: restore member count after dropping seats + :: + -(member-count ~(wyt by seats.group)) + =/ foreigns-8=foreigns:v8:gv + (~(run by foreigns) v8:foreign:v10:gc) + ``unsafe+noun+!>([groups-light-ui-11 foreigns-8]) + :: + [%x ver=?(%v0 %v1 %v2 %v3) %groups ~] + =/ groups-11=groups:v11:gv (~(run by groups) tail) + =* groups-9 (~(run by groups-11) v9:group:v11:gc) ?- ver.pole %v0 ``groups+(~(run by groups-9) v2:group:v9:gc) %v1 ``groups-1+(~(run by groups-9) v5:group:v9:gc) - %v2 ``groups-2+groups-9 + %v2 ``groups-2+`groups:v9:gv`groups-9 + %v3 ``groups-3+groups-11 == :: [%x ver=?(%v0 %v1 %v2) %light %groups ~] =/ groups-9=groups:v9:gv %- ~(run by groups) - |= [=net:v9:gv =group:v9:gv] - (drop-seats:group:v9:gc group our.bowl) + |= [=net:g =group:g] + (v9:group:v11:gc (drop-seats:group:v11:gc group our.bowl)) ?- ver.pole %v0 ``groups+(~(run by groups-9) v2:group:v9:gc) %v1 ``groups-1+(~(run by groups-9) v5:group:v9:gc) @@ -1261,18 +1327,17 @@ == :: [%x ver=?(%v0 %v1 %v2) %ui %groups ~] - =/ net-groups-9=net-groups:v9:gv groups ?- ver.pole %v0 =- ``groups-ui+- - %- ~(urn by net-groups-9) - |= [=flag:g =net:v9:gv =group:v9:gv] + %- ~(urn by groups) + |= [=flag:g =net:g =group:g] =/ =status:neg (read-status:neg bowl [p.flag %groups]) - (group-ui:v2:group:v9:gc status net group) + (group-ui:v2:group:v11:gc status net group) :: - %v1 ``groups-ui-1+(~(run by net-groups-9) group-ui:v5:group:v9:gc) - %v2 ``groups-ui-2+(~(run by net-groups-9) group-ui:group:v9:gc) + %v1 ``groups-ui-1+(~(run by groups) group-ui:v5:group:v11:gc) + %v2 ``groups-ui-2+(~(run by groups) group-ui:v9:group:v11:gc) == :: :: deprecated @@ -1284,15 +1349,21 @@ =+ since=(slav %da since.pole) :^ ~ ~ %group-changed-groups-1 - (~(run by (changes since)) group-ui:v5:group:v9:gc) + (~(run by (changes since)) group-ui:v5:group:v11:gc) :: [%x %v2 %changes since=@ rest=*] =+ since=(slav %da since.pole) :^ ~ ~ %group-changed-groups-2 - (~(run by (changes since)) group-ui:group:v9:gc) + (~(run by (changes since)) group-ui:v9:group:v11:gc) :: - [%x ver=?(%v0 %v1 %v2) %groups ship=@ name=@ rest=*] + [%x %v3 %changes since=@ rest=*] + =+ since=(slav %da since.pole) + :^ ~ ~ + %group-changed-groups-3 + (~(run by (changes since)) group-ui:group:v11:gc) + :: + [%x ver=?(%v0 %v1 %v2 %v3) %groups ship=@ name=@ rest=*] =+ ship=(slav %p ship.pole) =/ =flag:g [ship name.pole] =+ net-group=(~(get by groups) flag) @@ -1301,16 +1372,19 @@ (go-peek:(go-abed:go-core ship name.pole) ver.pole rest.pole) ?- ver.pole %v0 - ``group+(v2:group:v9:gc +.u.net-group) + ``group+(v2:group:v11:gc +.u.net-group) :: %v1 - ``group-1+(v5:group:v9:gc +.u.net-group) + ``group-1+(v5:group:v11:gc +.u.net-group) :: %v2 - ``group-2+`group:v9:gv`+.u.net-group + ``group-2+(v9:group:v11:gc +.u.net-group) + :: + %v3 + ``group-3+`group:v11:gv`+.u.net-group == :: - [%x ver=?(%v0 %v1 %v2) %ui %groups ship=@ name=@ rest=*] + [%x ver=?(%v0 %v1 %v2 %v3) %ui %groups ship=@ name=@ rest=*] =+ ship=(slav %p ship.pole) =/ =flag:g [ship name.pole] =+ net-group=(~(get by groups) flag) @@ -1324,13 +1398,16 @@ %v0 =/ =status:neg (read-status:neg bowl [p.flag %groups]) - ``group-ui+(group-ui:v2:group:v9:gc status u.net-group) + ``group-ui+(group-ui:v2:group:v11:gc status u.net-group) :: %v1 - ``group-ui-1+(group-ui:v5:group:v9:gc u.net-group) + ``group-ui-1+(group-ui:v5:group:v11:gc u.net-group) :: %v2 - ``group-ui-2+(group-ui:group:v9:gc u.net-group) + ``group-ui-2+(group-ui:v9:group:v11:gc u.net-group) + :: + %v3 + ``group-ui-3+(group-ui:group:v11:gc u.net-group) == :: :: deprecated @@ -1368,11 +1445,11 @@ ++ changes |= since=time ~> %spin.['changes'] - ^- (map flag:v9:gv [net:v9:gv group:v9:gv]) - %- ~(gas by *(map flag:v9:gv [net:v9:gv group:v9:gv])) + ^- (map flag:v11:gv [net:v11:gv group:v11:gv]) + %- ~(gas by *(map flag:v11:gv [net:v11:gv group:v11:gv])) %+ murn ~(tap in groups) |= [=flag:g =net:g =group:g] - ^- (unit [flag:v9:gv [net:v9:gv group:v9:gv]]) + ^- (unit [flag:v11:gv [net:v11:gv group:v11:gv]]) =/ fresh=? %+ lth since ?- -.net @@ -1382,7 +1459,7 @@ ?. fresh ~ %- some :- flag - [net (drop-seats:group:v9:gc group our.bowl)] + [net (drop-seats:group:v11:gc group our.bowl)] -- :: ++ agent @@ -1720,8 +1797,10 @@ (~(put in active-channels.group) nest) (~(del in active-channels.group) nest) =. groups (~(put by groups) flag net group) - =/ =r-groups:v10:gv [flag [%active-channel nest joined]] - (give %fact ~[/v2/groups] group-response-2+r-groups) + =/ r-groups-10=r-groups:v10:gv [flag [%active-channel nest joined]] + =. cor (give %fact ~[/v2/groups] group-response-2+r-groups-10) + =/ r-groups-11=r-groups:v11:gv [flag [%active-channel nest joined]] + (give %fact ~[/v3/groups] group-response-3+r-groups-11) :: ++ take-channels |= =sign:agent:gall @@ -2224,6 +2303,13 @@ ?: =(meta.group meta.c-group) se-core =. meta.group meta.c-group (se-update %meta meta.group) + :: + %blob + ?> se-src-is-admin + ?> (lte (met 3 (jam blob.c-group)) size-limit) + ?: =(blob.group blob.c-group) se-core + =. blob.group blob.c-group + (se-update %blob blob.group) :: %entry ?> se-src-is-admin @@ -3901,6 +3987,7 @@ ?- -.u-group %create (go-u-create group.u-group) %meta (go-u-meta data.u-group) + %blob (go-u-blob blob.u-group) %entry (go-u-entry u-entry.u-group) %seat (go-u-seat [ships u-seat]:u-group) %role (go-u-role [roles u-role]:u-group) @@ -3940,6 +4027,17 @@ :: =. meta.group meta go-core + :: +go-u-blob: apply custom payload update + :: + ++ go-u-blob + |= blob=(unit @t) + ~> %spin.['go-u-blob'] + ^+ go-core + =. go-core (go-response [%blob blob]) + ?: go-our-host go-core + :: + =. blob.group blob + go-core :: +go-u-entry: apply entry update :: ++ go-u-entry @@ -4586,18 +4684,29 @@ :: has been applied, and the group initialized. :: ?. go-is-init go-core - :: v1 response - :: - =/ r-groups-9=r-groups:v9:gv [flag r-group] + :: v3 response + :: + =/ r-groups-11=r-groups:v11:gv [flag r-group] + =/ v3-paths ~[/v3/groups [%v3 go-area]] + =. cor (give %fact v3-paths group-response-3+r-groups-11) + :: v2 response: %blob is v3-only + :: + =/ r-group-10=(unit r-group:v10:gv) (v10:r-group:v11:gc r-group) + =? cor ?=(^ r-group-10) + =/ r-groups-10=r-groups:v10:gv [flag u.r-group-10] + =/ v2-paths ~[/v2/groups [%v2 go-area]] + (give %fact v2-paths group-response-2+r-groups-10) + :: v1 response: %blob and %active-channel are absent + :: + =/ r-group-9=(unit r-group:v9:gv) (v9:r-group:v11:gc r-group) + ?~ r-group-9 go-core + =/ r-groups-9=r-groups:v9:gv [flag u.r-group-9] =/ v1-paths ~[/v1/groups [%v1 go-area]] =. cor (give %fact v1-paths group-response-1+r-groups-9) - =/ r-groups-10=r-groups:v10:gv [flag r-group] - =/ v2-paths ~[/v2/groups [%v2 go-area]] - =. cor (give %fact v2-paths group-response-2+r-groups-10) :: v0 backcompat :: =/ diffs-2=(list diff:v2:gv) - (diff:v2:r-group:v9:gc r-group [seats admissions]:group) + (diff:v2:r-group:v9:gc u.r-group-9 [seats admissions]:group) =. cor %+ roll diffs-2 |= [=diff:v2:gv =_cor] @@ -4607,7 +4716,7 @@ :: +go-peek: handle group scry request :: ++ go-peek - |= [ver=?(%v0 %v1 %v2) =(pole knot)] + |= [ver=?(%v0 %v1 %v2 %v3) =(pole knot)] ~> %spin.['go-peek'] ^- (unit (unit rail)) ::TODO some of these should be versioned, at least diff --git a/desk/lib/groups-conv.hoon b/desk/lib/groups-conv.hoon index 45e7915261..bc3091ca0b 100644 --- a/desk/lib/groups-conv.hoon +++ b/desk/lib/groups-conv.hoon @@ -5,6 +5,154 @@ :: =, gv |% +++ v11 + |% + ++ group + => + |% + ++ drop-seats + |= [=group:v11:gv our=ship] + ^- group:v11:gv + =. seats.group + =/ our-seat=seat:v9:gv + (~(gut by seats.group) our *seat:v9:gv) + =/ seats-number=@ud ~(wyt by seats.group) + ?: (lte seats-number 15) + seats.group :: keep all members if 15 or fewer + =/ other-ships=(list [ship seat:v9:gv]) + ~(tap by (~(del by seats.group) our)) + =/ keep-ships=(list [ship seat:v9:gv]) + :- [our our-seat] + (scag 14 other-ships) :: take first 14 other ships + (~(gas by *(map ship seat:v9:gv)) keep-ships) + group + -- + |% + ++ group-ui + |= [=net:v11:gv =group:v11:gv] + ^- group-ui:v11:gv + =/ init=? + ?: ?=(%pub -.net) & + !=(time.net *@da) + :* group + init + ~(wyt by seats.group) + == + ++ v9 + =< group + |% + ++ group + |= =group:v11:gv + ^- group:v9:gv + :* meta.group + :: + admissions.group + seats.group + :: + roles.group + admins.group + :: + channels.group + active-channels.group + :: + sections.group + section-order.group + :: + flagged-content.group + == + :: + ++ group-ui + |= [=net:v11:gv =group:v11:gv] + ^- group-ui:v9:gv + =/ init=? + ?: ?=(%pub -.net) & + !=(time.net *@da) + :* (^group group) + init + ~(wyt by seats.group) + == + -- + ++ v7 + =< group + |% + ++ group + |= =group:v11:gv + ^- group:v7:gv + %- v7:group:^v9 + (v9:^group group) + :: + ++ group-ui + |= [=net:v11:gv =group:v11:gv] + ^- group-ui:v7:gv + =/ init=? + ?: ?=(%pub -.net) & + !=(time.net *@da) + :* (^group group) + init + ~(wyt by seats.group) + == + -- + ++ v5 + =< group + |% + ++ group + |= =group:v11:gv + ^- group:v5:gv + %- v5:group:^v9 + (v9:^group group) + :: + ++ group-ui + |= [=net:v11:gv =group:v11:gv] + ^- group-ui:v5:gv + =/ init=? + ?: ?=(%pub -.net) & + !=(time.net *@da) + :* (^group group) + init + ~(wyt by seats.group) + == + -- + ++ v2 + =< group + |% + ++ group + |= =group:v11:gv + ^- group:v2:gv + %- v2:group:^v9 + (v9:^group group) + :: + ++ group-ui + |= [=status:neg =net:v11:gv =group:v11:gv] + ^- group-ui:v2:gv + ?. ?=(%sub -.net) + [(^group group) `[%chi ~]] + =/ saga=(unit saga:e) + ?+ status ~ + %match `[%chi ~] + %clash `[%lev ~] + == + [(^group group) saga] + -- + -- + ++ r-group + |% + ++ v10 + |= =r-group:v11:gv + ^- (unit r-group:v10:gv) + ?+ -.r-group `r-group + %blob ~ + %create `[%create (v9:group group.r-group)] + == + ++ v9 + |= =r-group:v11:gv + ^- (unit r-group:v9:gv) + ?+ -.r-group `r-group + %blob ~ + %active-channel ~ + %create `[%create (v9:group group.r-group)] + == + -- + -- ++ v10 |% ++ foreign @@ -70,6 +218,26 @@ init ~(wyt by seats.group) == + ++ v11 + |= =group:v9:gv + ^- group:v11:gv + :* meta.group + ~ + :: + admissions.group + seats.group + :: + roles.group + admins.group + :: + channels.group + active-channels.group + :: + sections.group + section-order.group + :: + flagged-content.group + == ++ v7 =< group |% @@ -135,6 +303,33 @@ [(^group group) saga] -- -- + ++ net + |% + ++ v11 + |= =net:v9:gv + ^- net:v11:gv + =* log-mp ((mp time u-group:v9:gv) lte) + ?: ?=(%sub -.net) net + :- %pub + ^- log:v11:gv + (urn:log-mp log.net v11:u-group) + -- + ++ u-group + |% + ++ v11 + |= [=time =u-group:v9:gv] + ^- u-group:v11:gv + ?: ?=([%create *] u-group) + [%create (v11:group group.u-group)] + u-group + -- + ++ net-group + |% + ++ v11 + |= [=net:v9:gv =group:v9:gv] + ^- [net:v11:gv group:v11:gv] + [(v11:^net net) (v11:^group group)] + -- ++ r-group |% ++ v2 diff --git a/desk/lib/groups-json.hoon b/desk/lib/groups-json.hoon index 96ec32d9d5..42b1a32c96 100644 --- a/desk/lib/groups-json.hoon +++ b/desk/lib/groups-json.hoon @@ -60,6 +60,88 @@ [%chat %club *] (scot %uv p.whom.w) == :: + ++ v11 + =, v9 + |% + ++ group + |= group:v11:gv + ^- json + %- pairs + ^- (list [@t json]) + :~ meta+(^meta meta) + blob+?~(blob ~ s+u.blob) + :: + admissions+(^admissions admissions) + seats+(^seats seats) + :: + roles+(roles-map roles) + admins+a+(turn ~(tap in admins) (lead %s)) + :: + channels+(^channels channels) + active-channels+a+(turn ~(tap in active-channels) nest) + :: + sections+(^sections sections) + section-order+a+(turn section-order (lead %s)) + :: + flagged-content+(^flagged-content flagged-content) + == + ++ groups + |= gs=groups:v11:gv + %- pairs + %+ turn ~(tap by gs) + |= [f=flag:gv gr=group:v11:gv] + [(print-flag f) (group gr)] + ++ groups-ui + |= gs=groups-ui:v11:gv + %- pairs + %+ turn ~(tap by gs) + |= [f=flag:gv gr=group-ui:v11:gv] + [(print-flag f) (group-ui gr)] + ++ group-ui + |= =group-ui:v11:gv + =, group.group-ui + %- pairs + ^- (list [@t json]) + :~ meta+(^meta meta) + blob+?~(b=blob.group.group-ui ~ s+u.b) + :: + admissions+(^admissions admissions) + seats+(^seats seats) + :: + roles+(roles-map roles) + admins+a+(turn ~(tap in admins) (lead %s)) + :: + channels+(^channels channels) + active-channels+a+(turn ~(tap in active-channels) nest) + :: + sections+(^sections sections) + section-order+a+(turn section-order (lead %s)) + :: + flagged-content+(^flagged-content flagged-content) + :: + init+b+init.group-ui + member-count+(numb member-count.group-ui) + == + ++ r-groups + |= =r-groups:v11:gv + ^- json + %- pairs + :~ 'flag'^(flag flag.r-groups) + 'r-group'^(r-group r-group.r-groups) + == + :: $r-group: superset of v10 with the %blob custom payload. + :: delegate the shared variants to v10's enjs. + :: + ++ r-group + |= =r-group:v11:gv + ^- json + ?: ?=([%blob *] r-group) + (frond %blob ?~(blob.r-group ~ s+u.blob.r-group)) + ?: ?=([%create *] r-group) + (frond %create (group group.r-group)) + (r-group:v10 r-group) + -- + :: ++ v9 =, v8 |% @@ -1327,6 +1409,32 @@ set-order+(ar whom) == :: + ++ v11 + =, v8 + |% + ++ a-groups + ^- $-(json a-groups:v11:gv) + %- of + :~ group+(ot flag+flag a-group+a-group ~) + invite+(ot flag+flag ships+(as ship) a-invite+a-invite ~) + leave+flag + == + ++ a-group + ^- $-(json a-group:v11:gv) + %- of + :~ meta+meta + blob+(mu so) + entry+a-entry:v7 + seat+(ot ships+ships:v7 a-seat+a-seat:v7 ~) + role+(ot roles+roles:v7 a-role+a-role:v7 ~) + channel+(ot nest+nest a-channel+a-channel:v7 ~) + section+(ot section-id+so a-section+a-section:v7 ~) + navigation+a-navigation + flag-content+flag-content + delete+ul + == + -- + :: ++ v9 =, v8 |% diff --git a/desk/lib/mark-warmer.hoon b/desk/lib/mark-warmer.hoon index 4a87a6b441..36676a62b8 100644 --- a/desk/lib/mark-warmer.hoon +++ b/desk/lib/mark-warmer.hoon @@ -6,6 +6,7 @@ /$ init-5 %ui-init-5 %json /$ init-6 %ui-init-6 %json /$ init-7 %ui-init-7 %json +/$ init-9 %ui-init-9 %json /$ heads %ui-heads %json /$ heads-2 %ui-heads-2 %json /$ heads-3 %ui-heads-3 %json @@ -83,7 +84,9 @@ /$ group-preview %group-preview %json /$ foreigns-1 %foreigns-1 %json /$ group-response %group-response-1 %json +/$ group-response-3 %group-response-3 %json /$ group-ui-2 %group-ui-2 %json +/$ group-ui-3 %group-ui-3 %json /$ full-info-1 %activity-full-1 %json /$ full-info-4 %activity-full-4 %json /$ act-update-1 %activity-update-1 %json @@ -95,6 +98,7 @@ /$ c-changed %channel-changed-posts %json /$ chat-changed %chat-changed-writs %json /$ group-changed %group-changed-groups-2 %json +/$ group-changed-3 %group-changed-groups-3 %json /$ contact-changed %contact-changed-pages %json /$ reel-bait %reel-bait %json /$ activity-allowed %activity-allowed %json @@ -111,6 +115,7 @@ /$ reel-describe %json %reel-describe /$ notify-client-action %json %notify-client-action /$ group-action-4 %json %group-action-4 +/$ group-action-5 %json %group-action-5 /$ activity-action %json %activity-action /$ activity-action-1 %json %activity-action-1 /$ channel-action-1 %json %channel-action-1 diff --git a/desk/lib/rail.hoon b/desk/lib/rail.hoon index d6d7f9365a..95e8f4e9c9 100644 --- a/desk/lib/rail.hoon +++ b/desk/lib/rail.hoon @@ -212,15 +212,18 @@ /% group %group /% group-1 %group-1 /% group-2 %group-2 +/% group-3 %group-3 /% group-action %group-action /% group-action-0 %group-action-0 /% group-action-1 %group-action-1 /% group-action-2 %group-action-2 /% group-action-3 %group-action-3 /% group-action-4 %group-action-4 +/% group-action-5 %group-action-5 /% group-cancel %group-cancel /% group-changed-groups-1 %group-changed-groups-1 /% group-changed-groups-2 %group-changed-groups-2 +/% group-changed-groups-3 %group-changed-groups-3 /% group-channel-active %group-channel-active /% group-channel-join %group-channel-join /% group-channel-leave %group-channel-leave @@ -249,10 +252,12 @@ /% group-rescind %group-rescind /% group-response-1 %group-response-1 /% group-response-2 %group-response-2 +/% group-response-3 %group-response-3 /% group-token %group-token /% group-ui %group-ui /% group-ui-1 %group-ui-1 /% group-ui-2 %group-ui-2 +/% group-ui-3 %group-ui-3 /% group-update %group-update /% grouper-answer-enabled %grouper-answer-enabled /% grouper-ask-enabled %grouper-ask-enabled @@ -261,6 +266,7 @@ /% groups %groups /% groups-1 %groups-1 /% groups-2 %groups-2 +/% groups-3 %groups-3 /% groups-ui %groups-ui /% groups-ui-1 %groups-ui-1 /% groups-ui-2 %groups-ui-2 @@ -338,6 +344,7 @@ /% ui-init-6 %ui-init-6 /% ui-init-7 %ui-init-7 /% ui-init-8 %ui-init-8 +/% ui-init-9 %ui-init-9 /% ui-pins %ui-pins /% ui-show-contact %ui-show-contact /% ui-vita %ui-vita @@ -565,15 +572,18 @@ $: %group $+ group p=_*vale:group == $: %group-1 $+ group-1 p=_*vale:group-1 == $: %group-2 $+ group-2 p=_*vale:group-2 == + $: %group-3 $+ group-3 p=_*vale:group-3 == $: %group-action $+ group-action p=_*vale:group-action == $: %group-action-0 $+ group-action-0 p=_*vale:group-action-0 == $: %group-action-1 $+ group-action-1 p=_*vale:group-action-1 == $: %group-action-2 $+ group-action-2 p=_*vale:group-action-2 == $: %group-action-3 $+ group-action-3 p=_*vale:group-action-3 == $: %group-action-4 $+ group-action-4 p=_*vale:group-action-4 == + $: %group-action-5 $+ group-action-5 p=_*vale:group-action-5 == $: %group-cancel $+ group-cancel p=_*vale:group-cancel == $: %group-changed-groups-1 $+ group-changed-groups-1 p=_*vale:group-changed-groups-1 == $: %group-changed-groups-2 $+ group-changed-groups-2 p=_*vale:group-changed-groups-2 == + $: %group-changed-groups-3 $+ group-changed-groups-3 p=_*vale:group-changed-groups-3 == $: %group-channel-active $+ group-channel-active p=_*vale:group-channel-active == $: %group-channel-join $+ group-channel-join p=_*vale:group-channel-join == $: %group-channel-leave $+ group-channel-leave p=_*vale:group-channel-leave == @@ -602,10 +612,12 @@ $: %group-rescind $+ group-rescind p=_*vale:group-rescind == $: %group-response-1 $+ group-response-1 p=_*vale:group-response-1 == $: %group-response-2 $+ group-response-2 p=_*vale:group-response-2 == + $: %group-response-3 $+ group-response-3 p=_*vale:group-response-3 == $: %group-token $+ group-token p=_*vale:group-token == $: %group-ui $+ group-ui p=_*vale:group-ui == $: %group-ui-1 $+ group-ui-1 p=_*vale:group-ui-1 == $: %group-ui-2 $+ group-ui-2 p=_*vale:group-ui-2 == + $: %group-ui-3 $+ group-ui-3 p=_*vale:group-ui-3 == $: %group-update $+ group-update p=_*vale:group-update == $: %grouper-answer-enabled $+ grouper-answer-enabled p=_*vale:grouper-answer-enabled == $: %grouper-ask-enabled $+ grouper-ask-enabled p=_*vale:grouper-ask-enabled == @@ -614,6 +626,7 @@ $: %groups $+ groups p=_*vale:groups == $: %groups-1 $+ groups-1 p=_*vale:groups-1 == $: %groups-2 $+ groups-2 p=_*vale:groups-2 == + $: %groups-3 $+ groups-3 p=_*vale:groups-3 == $: %groups-ui $+ groups-ui p=_*vale:groups-ui == $: %groups-ui-1 $+ groups-ui-1 p=_*vale:groups-ui-1 == $: %groups-ui-2 $+ groups-ui-2 p=_*vale:groups-ui-2 == @@ -691,6 +704,7 @@ $: %ui-init-6 $+ ui-init-6 p=_*vale:ui-init-6 == $: %ui-init-7 $+ ui-init-7 p=_*vale:ui-init-7 == $: %ui-init-8 $+ ui-init-8 p=_*vale:ui-init-8 == + $: %ui-init-9 $+ ui-init-9 p=_*vale:ui-init-9 == $: %ui-pins $+ ui-pins p=_*vale:ui-pins == $: %ui-show-contact $+ ui-show-contact p=_*vale:ui-show-contact == $: %ui-vita $+ ui-vita p=_*vale:ui-vita == @@ -920,15 +934,18 @@ %group [-.rail !>(+.rail)] %group-1 [-.rail !>(+.rail)] %group-2 [-.rail !>(+.rail)] + %group-3 [-.rail !>(+.rail)] %group-action [-.rail !>(+.rail)] %group-action-0 [-.rail !>(+.rail)] %group-action-1 [-.rail !>(+.rail)] %group-action-2 [-.rail !>(+.rail)] %group-action-3 [-.rail !>(+.rail)] %group-action-4 [-.rail !>(+.rail)] + %group-action-5 [-.rail !>(+.rail)] %group-cancel [-.rail !>(+.rail)] %group-changed-groups-1 [-.rail !>(+.rail)] %group-changed-groups-2 [-.rail !>(+.rail)] + %group-changed-groups-3 [-.rail !>(+.rail)] %group-channel-active [-.rail !>(+.rail)] %group-channel-join [-.rail !>(+.rail)] %group-channel-leave [-.rail !>(+.rail)] @@ -957,10 +974,12 @@ %group-rescind [-.rail !>(+.rail)] %group-response-1 [-.rail !>(+.rail)] %group-response-2 [-.rail !>(+.rail)] + %group-response-3 [-.rail !>(+.rail)] %group-token [-.rail !>(+.rail)] %group-ui [-.rail !>(+.rail)] %group-ui-1 [-.rail !>(+.rail)] %group-ui-2 [-.rail !>(+.rail)] + %group-ui-3 [-.rail !>(+.rail)] %group-update [-.rail !>(+.rail)] %grouper-answer-enabled [-.rail !>(+.rail)] %grouper-ask-enabled [-.rail !>(+.rail)] @@ -969,6 +988,7 @@ %groups [-.rail !>(+.rail)] %groups-1 [-.rail !>(+.rail)] %groups-2 [-.rail !>(+.rail)] + %groups-3 [-.rail !>(+.rail)] %groups-ui [-.rail !>(+.rail)] %groups-ui-1 [-.rail !>(+.rail)] %groups-ui-2 [-.rail !>(+.rail)] @@ -1046,6 +1066,7 @@ %ui-init-6 [-.rail !>(+.rail)] %ui-init-7 [-.rail !>(+.rail)] %ui-init-8 [-.rail !>(+.rail)] + %ui-init-9 [-.rail !>(+.rail)] %ui-pins [-.rail !>(+.rail)] %ui-show-contact [-.rail !>(+.rail)] %ui-vita [-.rail !>(+.rail)] @@ -1273,15 +1294,18 @@ %group [p !<(_*vale:group q)] %group-1 [p !<(_*vale:group-1 q)] %group-2 [p !<(_*vale:group-2 q)] + %group-3 [p !<(_*vale:group-3 q)] %group-action [p !<(_*vale:group-action q)] %group-action-0 [p !<(_*vale:group-action-0 q)] %group-action-1 [p !<(_*vale:group-action-1 q)] %group-action-2 [p !<(_*vale:group-action-2 q)] %group-action-3 [p !<(_*vale:group-action-3 q)] %group-action-4 [p !<(_*vale:group-action-4 q)] + %group-action-5 [p !<(_*vale:group-action-5 q)] %group-cancel [p !<(_*vale:group-cancel q)] %group-changed-groups-1 [p !<(_*vale:group-changed-groups-1 q)] %group-changed-groups-2 [p !<(_*vale:group-changed-groups-2 q)] + %group-changed-groups-3 [p !<(_*vale:group-changed-groups-3 q)] %group-channel-active [p !<(_*vale:group-channel-active q)] %group-channel-join [p !<(_*vale:group-channel-join q)] %group-channel-leave [p !<(_*vale:group-channel-leave q)] @@ -1310,10 +1334,12 @@ %group-rescind [p !<(_*vale:group-rescind q)] %group-response-1 [p !<(_*vale:group-response-1 q)] %group-response-2 [p !<(_*vale:group-response-2 q)] + %group-response-3 [p !<(_*vale:group-response-3 q)] %group-token [p !<(_*vale:group-token q)] %group-ui [p !<(_*vale:group-ui q)] %group-ui-1 [p !<(_*vale:group-ui-1 q)] %group-ui-2 [p !<(_*vale:group-ui-2 q)] + %group-ui-3 [p !<(_*vale:group-ui-3 q)] %group-update [p !<(_*vale:group-update q)] %grouper-answer-enabled [p !<(_*vale:grouper-answer-enabled q)] %grouper-ask-enabled [p !<(_*vale:grouper-ask-enabled q)] @@ -1322,6 +1348,7 @@ %groups [p !<(_*vale:groups q)] %groups-1 [p !<(_*vale:groups-1 q)] %groups-2 [p !<(_*vale:groups-2 q)] + %groups-3 [p !<(_*vale:groups-3 q)] %groups-ui [p !<(_*vale:groups-ui q)] %groups-ui-1 [p !<(_*vale:groups-ui-1 q)] %groups-ui-2 [p !<(_*vale:groups-ui-2 q)] @@ -1399,6 +1426,7 @@ %ui-init-6 [p !<(_*vale:ui-init-6 q)] %ui-init-7 [p !<(_*vale:ui-init-7 q)] %ui-init-8 [p !<(_*vale:ui-init-8 q)] + %ui-init-9 [p !<(_*vale:ui-init-9 q)] %ui-pins [p !<(_*vale:ui-pins q)] %ui-show-contact [p !<(_*vale:ui-show-contact q)] %ui-vita [p !<(_*vale:ui-vita q)] @@ -1626,15 +1654,18 @@ :- %group -:!>(*vale:group) :- %group-1 -:!>(*vale:group-1) :- %group-2 -:!>(*vale:group-2) + :- %group-3 -:!>(*vale:group-3) :- %group-action -:!>(*vale:group-action) :- %group-action-0 -:!>(*vale:group-action-0) :- %group-action-1 -:!>(*vale:group-action-1) :- %group-action-2 -:!>(*vale:group-action-2) :- %group-action-3 -:!>(*vale:group-action-3) :- %group-action-4 -:!>(*vale:group-action-4) + :- %group-action-5 -:!>(*vale:group-action-5) :- %group-cancel -:!>(*vale:group-cancel) :- %group-changed-groups-1 -:!>(*vale:group-changed-groups-1) :- %group-changed-groups-2 -:!>(*vale:group-changed-groups-2) + :- %group-changed-groups-3 -:!>(*vale:group-changed-groups-3) :- %group-channel-active -:!>(*vale:group-channel-active) :- %group-channel-join -:!>(*vale:group-channel-join) :- %group-channel-leave -:!>(*vale:group-channel-leave) @@ -1663,10 +1694,12 @@ :- %group-rescind -:!>(*vale:group-rescind) :- %group-response-1 -:!>(*vale:group-response-1) :- %group-response-2 -:!>(*vale:group-response-2) + :- %group-response-3 -:!>(*vale:group-response-3) :- %group-token -:!>(*vale:group-token) :- %group-ui -:!>(*vale:group-ui) :- %group-ui-1 -:!>(*vale:group-ui-1) :- %group-ui-2 -:!>(*vale:group-ui-2) + :- %group-ui-3 -:!>(*vale:group-ui-3) :- %group-update -:!>(*vale:group-update) :- %grouper-answer-enabled -:!>(*vale:grouper-answer-enabled) :- %grouper-ask-enabled -:!>(*vale:grouper-ask-enabled) @@ -1675,6 +1708,7 @@ :- %groups -:!>(*vale:groups) :- %groups-1 -:!>(*vale:groups-1) :- %groups-2 -:!>(*vale:groups-2) + :- %groups-3 -:!>(*vale:groups-3) :- %groups-ui -:!>(*vale:groups-ui) :- %groups-ui-1 -:!>(*vale:groups-ui-1) :- %groups-ui-2 -:!>(*vale:groups-ui-2) @@ -1752,6 +1786,7 @@ :- %ui-init-6 -:!>(*vale:ui-init-6) :- %ui-init-7 -:!>(*vale:ui-init-7) :- %ui-init-8 -:!>(*vale:ui-init-8) + :- %ui-init-9 -:!>(*vale:ui-init-9) :- %ui-pins -:!>(*vale:ui-pins) :- %ui-show-contact -:!>(*vale:ui-show-contact) :- %ui-vita -:!>(*vale:ui-vita) diff --git a/desk/mar/group-3.hoon b/desk/mar/group-3.hoon new file mode 100644 index 0000000000..685d688cb8 --- /dev/null +++ b/desk/mar/group-3.hoon @@ -0,0 +1,14 @@ +/- gv=groups-ver +/+ j=groups-json +|_ =group:v11:gv +++ grad %noun +++ grow + |% + ++ noun group + ++ json (group:v11:enjs:j group) + -- +++ grab + |% + ++ noun group:v11:gv + -- +-- diff --git a/desk/mar/group-ui-3.hoon b/desk/mar/group-ui-3.hoon new file mode 100644 index 0000000000..2662d73d2c --- /dev/null +++ b/desk/mar/group-ui-3.hoon @@ -0,0 +1,14 @@ +/- gv=groups-ver +/+ j=groups-json +|_ =group-ui:v11:gv +++ grad %noun +++ grow + |% + ++ noun group-ui + ++ json (group-ui:v11:enjs:j group-ui) + -- +++ grab + |% + ++ noun group-ui:v11:gv + -- +-- diff --git a/desk/mar/group/action-5.hoon b/desk/mar/group/action-5.hoon new file mode 100644 index 0000000000..7b2181070c --- /dev/null +++ b/desk/mar/group/action-5.hoon @@ -0,0 +1,14 @@ +/- gv=groups-ver +/+ j=groups-json +|_ =a-groups:v11:gv +++ grad %noun +++ grow + |% + ++ noun a-groups + -- +++ grab + |% + ++ noun a-groups:v11:gv + ++ json a-groups:v11:dejs:j + -- +-- diff --git a/desk/mar/group/changed-groups-3.hoon b/desk/mar/group/changed-groups-3.hoon new file mode 100644 index 0000000000..0e08988fa4 --- /dev/null +++ b/desk/mar/group/changed-groups-3.hoon @@ -0,0 +1,20 @@ +/- gv=groups-ver +/+ j=groups-json +|_ changes=(map flag:v11:gv group-ui:v11:gv) +++ grad %noun +++ grow + |% + ++ noun changes + ++ json + ^- ^json + %- pairs:enjs:format + %+ turn ~(tap by changes) + |= [=flag:v11:gv =group-ui:v11:gv] + :- (print-flag:v11:enjs:j flag) + (group-ui:v11:enjs:j group-ui) + -- +++ grab + |% + ++ noun (map flag:v11:gv group-ui:v11:gv) + -- +-- diff --git a/desk/mar/group/response-3.hoon b/desk/mar/group/response-3.hoon new file mode 100644 index 0000000000..0d6837b6d5 --- /dev/null +++ b/desk/mar/group/response-3.hoon @@ -0,0 +1,14 @@ +/- gv=groups-ver +/+ j=groups-json +|_ =r-groups:v11:gv +++ grad %noun +++ grow + |% + ++ noun r-groups + ++ json (r-groups:v11:enjs:j r-groups) + -- +++ grab + |% + ++ noun r-groups:v11:gv + -- +-- diff --git a/desk/mar/groups-3.hoon b/desk/mar/groups-3.hoon new file mode 100644 index 0000000000..67624ec44d --- /dev/null +++ b/desk/mar/groups-3.hoon @@ -0,0 +1,14 @@ +/- gv=groups-ver +/+ j=groups-json +|_ =groups:v11:gv +++ grad %noun +++ grow + |% + ++ noun groups + ++ json (groups:v11:enjs:j groups) + -- +++ grab + |% + ++ noun groups:v11:gv + -- +-- diff --git a/desk/mar/ui/init-9.hoon b/desk/mar/ui/init-9.hoon new file mode 100644 index 0000000000..d347b082f5 --- /dev/null +++ b/desk/mar/ui/init-9.hoon @@ -0,0 +1,38 @@ +/- u=ui +/+ gj=groups-json, cj=chat-json, dj=channel-json, aj=activity-json +|_ init=init-9:u +++ grad %noun +++ grow + |% + ++ noun init + ++ json + =, enjs:format + ^- ^json + %- pairs + :~ groups/(groups-ui:v11:enjs:gj groups.init) + foreigns/(foreigns:v8:enjs:gj foreigns.init) + activity/(activity:v8:enjs:aj activity.init |) + pins/a/(turn pins.init whom:enjs:gj) + profile/b/profile.init + :: + :- %channel + %- pairs + :~ channels/(channels:v10:enjs:dj channels.channel.init) + hidden-posts/(hidden-posts:v10:enjs:dj hidden-posts.channel.init) + == + :- %chat + %- pairs + :~ clubs/(clubs:enjs:cj clubs.chat.init) + dms/a/(turn ~(tap in dms.chat.init) ship:enjs:gj) + invited/a/(turn ~(tap in invited.chat.init) ship:enjs:gj) + blocked/a/(turn ~(tap in blocked.chat.init) ship:enjs:gj) + blocked-by/a/(turn ~(tap in blocked-by.chat.init) ship:enjs:gj) + hidden-messages/(hidden-messages:enjs:cj hidden-messages.chat.init) + == + == + -- +++ grab + |% + ++ noun init-9:u + -- +-- diff --git a/desk/sur/groups-ver.hoon b/desk/sur/groups-ver.hoon index 39a4775cc9..cebc2a0ad5 100644 --- a/desk/sur/groups-ver.hoon +++ b/desk/sur/groups-ver.hoon @@ -10,6 +10,146 @@ :: :: versions :: +++ v11 + =, v10 + |% + :: $group: modified + :: + :: .blob: custom payload + :: + +$ group + $: meta=data:meta + blob=(unit @t) + :: + =admissions + seats=(map ship seat) + :: + roles=(map role-id role) + =admins + :: + channels=(map nest channel) + active-channels=(set nest) + :: + sections=(map section-id section) + section-order=(list section-id) + :: + =flagged-content + == + :: $group-ui: depends on $group + +$ group-ui + $: =group + init=? + member-count=@ud + == + :: $groups-ui: depends on $group-ui + +$ groups-ui + (map flag group-ui) + :: $groups: depends on $group + +$ groups + (map flag group) + :: $net: depends on $log + +$ net + $~ [%pub ~] + $% [%pub =log] + [%sub =time init=_|] + == + :: $net-groups: depends on $group and $net + +$ net-groups + (map flag [net group]) + :: $init: depends on $group + +$ init [=time =group] + :: $update: depends on $u-group + +$ update [=time =u-group] + :: $u-group: modified, depends on $group + :: + :: %blob: update the custom payload + :: + +$ u-group + $% [%create =group] + [%meta =data:meta] + [%blob blob=(unit @t)] + [%entry =u-entry] + [%seat ships=(set ship) =u-seat] + [%role roles=(set role-id) =u-role] + [%channel =nest =u-channel] + [%section =section-id =u-section] + [%section-order order=(list section-id)] + [%flag-content =nest =plan src=ship] + [%delete ~] + == + :: $log: depends on $u-group + +$ log ((mop time u-group) lte) + :: $log-on: depends on $u-group + ++ log-on ((on time u-group) lte) + :: $r-groups: depends on $r-group + +$ r-groups [=flag =r-group] + :: $r-group: modified, depends on $group + :: + :: %blob: update the custom payload + :: + +$ r-group + $% [%create =group] + [%meta meta=data:meta] + [%blob blob=(unit @t)] + [%entry =r-entry] + [%seat ships=(set ship) =r-seat] + [%role roles=(set role-id) =r-role] + [%channel =nest =r-channel] + [%section =section-id =r-section] + [%section-order order=(list section-id)] + [%flag-content =nest =plan src=ship] + [%active-channel =nest joined=?] + [%delete ~] + == + :: $a-groups: depends on $a-group + +$ a-groups + $% [%group =flag =a-group] + [%invite =flag ships=(set ship) =a-invite] + [%leave =flag] + == + :: $a-group: modified + :: + :: %blob: update the custom payload + :: + +$ a-group + $% [%meta meta=data:meta] + [%blob blob=(unit @t)] + [%entry =a-entry] + [%seat ships=(set ship) =a-seat] + [%role roles=(set role-id) =a-role] + [%channel =nest =a-channel] + [%section =section-id =a-section] + [%navigation =a-navigation] + [%flag-content =nest =plan src=ship] + [%delete ~] + == + :: $c-groups: depends on $c-group + +$ c-groups + $% [%create =create-group] + [%group =flag =c-group] + :: + [%ask =flag story=(unit story:s)] + [%join =flag token=(unit token)] + :: + [%leave =flag] + == + :: $c-group: modified + :: + :: %blob: update the custom payload + :: + +$ c-group + $% [%meta meta=data:meta] + [%blob blob=(unit @t)] + [%entry =c-entry] + [%seat ships=(set ship) =c-seat] + [%role roles=(set role-id) =c-role] + [%channel =nest =c-channel] + [%section =section-id =c-section] + [%section-order order=(list section-id)] + [%flag-content =nest =plan src=ship] + [%delete ~] + == + -- ++ v10 =, v9 |% diff --git a/desk/sur/groups.hoon b/desk/sur/groups.hoon index 65c0e83dd3..295427d40a 100644 --- a/desk/sur/groups.hoon +++ b/desk/sur/groups.hoon @@ -188,6 +188,7 @@ :: organization, and its own metadata to represent the group :: :: .meta: group metadata +:: .blob: custom payload :: .admissions: entry policy :: .seats: members :: .roles: member roles @@ -200,6 +201,7 @@ :: +$ group $: meta=data:meta + blob=(unit @t) :: =admissions seats=(map ship seat) @@ -322,6 +324,7 @@ == +$ a-group $% [%meta meta=data:meta] + [%blob blob=(unit @t)] [%entry =a-entry] [%seat ships=(set ship) =a-seat] [%role roles=(set role-id) =a-role] @@ -375,6 +378,7 @@ :: $c-group: group command :: :: %meta: update the metadata +:: %blob: update the custom payload :: %entry: update the entry policy :: %seat: update seats :: %role: update roles @@ -385,6 +389,7 @@ :: +$ c-group $% [%meta meta=data:meta] + [%blob blob=(unit @t)] [%entry =c-entry] [%seat ships=(set ship) =c-seat] [%role roles=(set role-id) =c-role] @@ -495,6 +500,7 @@ +$ u-group $% [%create =group] [%meta =data:meta] + [%blob blob=(unit @t)] [%entry =u-entry] [%seat ships=(set ship) =u-seat] [%role roles=(set role-id) =u-role] @@ -567,6 +573,7 @@ +$ r-group $% [%create =group] [%meta meta=data:meta] + [%blob blob=(unit @t)] [%entry =r-entry] [%seat ships=(set ship) =r-seat] [%role roles=(set role-id) =r-role] diff --git a/desk/sur/ui.hoon b/desk/sur/ui.hoon index 7b372bc26e..debf2472ba 100644 --- a/desk/sur/ui.hoon +++ b/desk/sur/ui.hoon @@ -1,5 +1,14 @@ /- av=activity-ver, gv=groups-ver, dv=channels-ver, cv=chat-ver, a=activity |% ++$ init-9 + $: groups=groups-ui:v11:gv + =foreigns:v8:gv + channel=channel-10 + =activity:v8:av + pins=(list whom) + chat=chat-2 + profile=? + == +$ init-8 $: groups=groups-ui:v9:gv =foreigns:v8:gv diff --git a/desk/tests/app/groups-server.hoon b/desk/tests/app/groups-server.hoon index eddb8395ed..3b4c387d6e 100644 --- a/desk/tests/app/groups-server.hoon +++ b/desk/tests/app/groups-server.hoon @@ -121,7 +121,7 @@ :: :: ++ ex-u-groups - |= [caz=(list card) us-groups=(list u-group:v9:gv)] + |= [caz=(list card) us-groups=(list u-group:g)] =/ m (mare ,~) ^- form:m ;< =bowl:gall bind:m get-bowl @@ -129,7 +129,7 @@ (turn us-groups (cury ex-update now.bowl)) :: ++ ex-update - |= [=time =u-group:v9:gv] + |= [=time =u-group:g] %+ ex-fact ~[/server/groups/~zod/my-test-group/updates/~zod/(scot:h136 %da *@da)] group-update+!>(`update:g`[time u-group]) @@ -144,7 +144,7 @@ ^- form:m ;< * bind:m do-groups-init ;< caz=(list card) bind:m (do-create-group %secret) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m %+ ex-cards caz @@ -285,7 +285,7 @@ ;< =bowl bind:m get-bowl ;< ~ bind:m (ex-u-groups caz [%entry %ask [%add ~dev now.bowl `story]]~) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m %+ ex-equal @@ -361,7 +361,7 @@ ;< =bowl:gall bind:m get-bowl ;< ~ bind:m (ex-u-groups caz [%entry %ask [%add ~dev now.bowl `story]]~) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m %+ ex-equal @@ -395,7 +395,7 @@ ['New Title' 'New description' '' ''] ;< caz=(list card) bind:m ((do-as ~zod) (do-c-group [%meta meta])) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m (ex-equal !>(meta.group) !>(meta)) @@ -408,6 +408,51 @@ :: non-members can't update metadata ;< ~ bind:m (ex-fail ((do-as ~fed) (do-c-group [%meta meta]))) (pure:m ~) +:: +test-c-group-blob: test group custom payload update +:: +:: the group blob can only be updated by an admin. +:: +++ test-c-group-blob + %- eval-mare + =/ m (mare ,~) + ^- form:m + ;< * bind:m do-groups-init + ;< * bind:m (do-create-group %public) + :: the blob starts out empty + :: + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) + =+ !<(=group:g q.peek) + ;< ~ bind:m + (ex-equal !>(blob.group) !>(~)) + :: update the group blob as the host + :: + =/ blob=(unit @t) `'{"custom":"payload"}' + ;< caz=(list card) bind:m + ((do-as ~zod) (do-c-group [%blob blob])) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) + =+ !<(=group:g q.peek) + ;< ~ bind:m + (ex-equal !>(blob.group) !>(blob)) + ;< ~ bind:m + (ex-u-groups caz [%blob blob]~) + :: setting an identical blob emits no update + :: + ;< caz=(list card) bind:m + ((do-as ~zod) (do-c-group [%blob blob])) + ;< ~ bind:m (ex-cards caz ~) + :: clearing the blob emits an update + :: + ;< caz=(list card) bind:m + ((do-as ~zod) (do-c-group [%blob ~])) + ;< ~ bind:m + (ex-u-groups caz [%blob ~]~) + :: non-admin members can't update the blob + :: + ;< * bind:m (do-join-group ~dev) + ;< ~ bind:m (ex-fail ((do-as ~dev) (do-c-group [%blob blob]))) + :: non-members can't update the blob + ;< ~ bind:m (ex-fail ((do-as ~fed) (do-c-group [%blob blob]))) + (pure:m ~) :: +test-c-group-entry-privacy: test group privacy update :: :: group privacy can only be updated by a group host or an admin. @@ -424,7 +469,7 @@ ((do-as ~zod) (do-c-group [%entry %privacy %secret])) ;< ~ bind:m (ex-u-groups caz [%entry %privacy %secret]~) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m (ex-equal !>(privacy.admissions.group) !>(%secret)) @@ -444,7 +489,7 @@ ((do-as ~zod) (do-c-group [%seat (sy ~dev ~) %add-roles (sy %admin ~)])) ;< * bind:m ((do-as ~dev) (do-c-group [%entry %privacy %secret])) - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) ;< ~ bind:m (ex-equal !>(privacy.admissions.group) !>(%secret)) @@ -599,7 +644,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -684,7 +729,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -722,7 +767,7 @@ (ex-update now.bowl [%entry %pending %add (sy ~fun ~) ~]) == ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -764,7 +809,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -790,7 +835,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~dev)) @@ -830,7 +875,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -854,7 +899,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~dev)) @@ -896,7 +941,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -921,7 +966,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~dev)) @@ -971,7 +1016,7 @@ :: typed values. :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -992,8 +1037,9 @@ :~ (ex-poke (snoc revoke-wire ~.~dev) [~dev my-agent] group-foreign-2+!>([%revoke my-flag `token0])) (ex-poke (snoc revoke-wire ~.~fun) [~fun my-agent] group-foreign-2+!>([%revoke my-flag `token1])) (ex-update now.bowl [%delete ~]) - (ex-fact-paths ~[/v1/groups /v1/groups/(scot %p p:my-flag)/[q:my-flag]]) + (ex-fact-paths ~[/v3/groups /v3/groups/(scot %p p:my-flag)/[q:my-flag]]) (ex-fact-paths ~[/v2/groups /v2/groups/(scot %p p:my-flag)/[q:my-flag]]) + (ex-fact-paths ~[/v1/groups /v1/groups/(scot %p p:my-flag)/[q:my-flag]]) (ex-fact-paths ~[/groups/ui]) (ex-task (weld go-area /updates) [~zod my-agent] %leave ~) == diff --git a/desk/tests/app/groups.hoon b/desk/tests/app/groups.hoon index 1939e486bb..ffa2d71bf0 100644 --- a/desk/tests/app/groups.hoon +++ b/desk/tests/app/groups.hoon @@ -13,6 +13,8 @@ ++ my-group ^- group:g :* meta=[title='My Test Group' description='A testing group' image='' cover=''] + :: + blob=~ :: ^= admissions :* privacy=%public @@ -108,7 +110,7 @@ =/ m (mare ,(list card)) ^- form:m ;< ~ bind:m (wait ~m1) - (do-poke group-action-4+!>(`a-groups:v8:gv`a-groups)) + (do-poke group-action-5+!>(`a-groups:v11:gv`a-groups)) :: ++ do-a-group |= =a-group:g @@ -116,7 +118,7 @@ ^- form:m ;< ~ bind:m (wait ~m1) =/ =a-groups:g [%group my-flag a-group] - (do-poke group-action-4+!>(`a-groups:v8:gv`a-groups)) + (do-poke group-action-5+!>(`a-groups:v11:gv`a-groups)) :: ++ do-a-foreigns |= =a-foreigns:g @@ -134,51 +136,64 @@ (do-poke group-foreign-2+!>(`a-foreigns:v9:gv`a-foreigns)) :: ++ ex-r-groups - |= [caz=(list card) rs-groups=(list r-groups:v9:gv)] + |= [caz=(list card) rs-groups=(list r-groups:g)] =/ m (mare ,~) ^- form:m ;< =bowl:gall bind:m get-bowl - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) + %+ ex-cards caz + %- zing + %+ turn rs-groups + |= =r-groups:g + ^- (list $-(card tang)) + =/ r-group-10=(unit r-group:v10:gv) + (v10:r-group:v11:gc r-group.r-groups) + =/ r-group-9=(unit r-group:v9:gv) + (v9:r-group:v11:gc r-group.r-groups) =/ actions-2=(list action:v2:gv) - %- zing - %+ turn rs-groups - |= =r-groups:v9:gv + ?~ r-group-9 ~ %+ turn - (diff:v2:r-group:v9:gc r-group.r-groups [seats admissions]:group) + (diff:v2:r-group:v9:gc u.r-group-9 [seats admissions]:group) |= =diff:v2:gv [flag.r-groups now.bowl diff] - %+ ex-cards caz - %+ welp - %- zing - %+ turn rs-groups - |= =r-groups:v9:gv - :~ %+ ex-fact ~[/v1/groups /v1/groups/~zod/my-test-group] - group-response-1+!>(r-groups) - %+ ex-fact ~[/v2/groups /v2/groups/~zod/my-test-group] - group-response-2+!>(`r-groups:v10:gv`r-groups) - == - %+ turn actions-2 - |= =action:v2:gv - (ex-fact ~[/groups/ui] group-action-3+!>(action)) + ;: welp + :_ ~ + %+ ex-fact ~[/v3/groups /v3/groups/~zod/my-test-group] + group-response-3+!>(`r-groups:v11:gv`r-groups) + :: + ?~ r-group-10 ~ + :_ ~ + %+ ex-fact ~[/v2/groups /v2/groups/~zod/my-test-group] + group-response-2+!>(`r-groups:v10:gv`[flag.r-groups u.r-group-10]) + :: + ?~ r-group-9 ~ + :_ ~ + %+ ex-fact ~[/v1/groups /v1/groups/~zod/my-test-group] + group-response-1+!>(`r-groups:v9:gv`[flag.r-groups u.r-group-9]) + :: + %+ turn actions-2 + |= =action:v2:gv + (ex-fact ~[/groups/ui] group-action-3+!>(action)) + == :: ++ ex-cards-r-groups |= $: caz=(list card) - exes=(list (each $-(card tang) r-groups:v9:gv)) + exes=(list (each $-(card tang) r-groups:g)) == =/ m (mare ,~) ^- form:m :: extract group - it is needed for facts down-conversion :: ;< =bowl:gall bind:m get-bowl - ;< peek=cage bind:m (got-peek /x/v2/groups/~zod/my-test-group) + ;< peek=cage bind:m (got-peek /x/v3/groups/~zod/my-test-group) =+ !<(=group:g q.peek) :: assemble expected :: %+ ex-cards caz %- flop %+ roll exes - |= [exe=(each $-(card tang) r-groups:v9:gv) out=(list $-(card tang))] + |= [exe=(each $-(card tang) r-groups:g) out=(list $-(card tang))] ?: ?=(%& -.exe) :: expected card :: @@ -186,20 +201,35 @@ :: expected r-groups :: =* r-groups p.exe + =/ r-group-10=(unit r-group:v10:gv) + (v10:r-group:v11:gc r-group.r-groups) + =/ r-group-9=(unit r-group:v9:gv) + (v9:r-group:v11:gc r-group.r-groups) =/ actions-2=(list action:v2:gv) + ?~ r-group-9 ~ %+ turn - (diff:v2:r-group:v9:gc r-group.r-groups [seats admissions]:group) + (diff:v2:r-group:v9:gc u.r-group-9 [seats admissions]:group) |= =diff:v2:gv [flag.r-groups now.bowl diff] - %+ welp + ;: welp %+ turn actions-2 |= =action:v2:gv (ex-fact ~[/groups/ui] group-action-3+!>(action)) - :- %+ ex-fact ~[/v2/groups /v2/groups/~zod/my-test-group] - group-response-2+!>(`r-groups:v10:gv`r-groups) - :- %+ ex-fact ~[/v1/groups /v1/groups/~zod/my-test-group] - group-response-1+!>(r-groups) - out + :: + ?~ r-group-9 ~ + :_ ~ + %+ ex-fact ~[/v1/groups /v1/groups/~zod/my-test-group] + group-response-1+!>(`r-groups:v9:gv`[flag.r-groups u.r-group-9]) + :: + ?~ r-group-10 ~ + :_ ~ + %+ ex-fact ~[/v2/groups /v2/groups/~zod/my-test-group] + group-response-2+!>(`r-groups:v10:gv`[flag.r-groups u.r-group-10]) + :: + :_ out + %+ ex-fact ~[/v3/groups /v3/groups/~zod/my-test-group] + group-response-3+!>(`r-groups:v11:gv`r-groups) + == :: ++ get-invite |= tok=(unit token:g) @@ -396,7 +426,7 @@ :: verify the invitee is recorded on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -431,7 +461,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~fun)) @@ -485,7 +515,7 @@ :: verify the invitee is recorded on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) :: verify records on the invited list :: @@ -518,7 +548,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~fun)) @@ -577,7 +607,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~fun)) @@ -635,7 +665,7 @@ :: verify records on the invited list :: ;< peek=cage bind:m - (got-peek /x/v2/groups/(scot %p p:my-flag)/[q:my-flag]) + (got-peek /x/v3/groups/(scot %p p:my-flag)/[q:my-flag]) =+ group=!<(group:g q.peek) ;< ~ bind:m %+ ex-equal !>((~(get by invited.admissions.group) ~fun)) @@ -688,8 +718,9 @@ ;< ~ bind:m %+ ex-cards caz :~ (ex-poke (weld go-area /invite/revoke/~fun) [~fun my-agent] group-foreign-2+!>([%revoke my-flag `0v123])) - (ex-fact-paths ~[/v1/groups /v1/groups/(scot %p p:my-flag)/[q:my-flag]]) + (ex-fact-paths ~[/v3/groups /v3/groups/(scot %p p:my-flag)/[q:my-flag]]) (ex-fact-paths ~[/v2/groups /v2/groups/(scot %p p:my-flag)/[q:my-flag]]) + (ex-fact-paths ~[/v1/groups /v1/groups/(scot %p p:my-flag)/[q:my-flag]]) (ex-fact-paths ~[/groups/ui]) (ex-task (weld go-area /updates) [~zod my-agent] %leave ~) == From b7ec149ba9951d2b78a418704b78439d6deaa5cd Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 30 Jul 2026 10:59:58 -0500 Subject: [PATCH 02/25] api/shared: plumb group blob through the client stack Wire types for the %blob action/response, updateGroupBlob poke via group-action-5 tracked on /v3/groups, a third groups subscription filtered to blob events, getGroup/getGroups moved to the v3 scries, init/changes bumped to /v9/init and /v10/changes, a groups.blob db column, editGroupBlob sync handling, and an optimistic updateGroupBlob store action. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/changesApi.ts | 3 +- packages/api/src/client/groupsApi.test.ts | 28 ++++++- packages/api/src/client/groupsApi.ts | 78 ++++++++++++++++++- packages/api/src/client/initApi.ts | 3 +- packages/api/src/types/models.ts | 1 + packages/api/src/urbit/groups.ts | 3 + ...s_twister.sql => 0000_late_micromacro.sql} | 3 +- .../src/db/migrations/meta/0000_snapshot.json | 11 ++- .../src/db/migrations/meta/_journal.json | 6 +- .../shared/src/db/migrations/migrations.js | 3 +- packages/shared/src/db/queries.ts | 1 + packages/shared/src/db/schema.ts | 1 + packages/shared/src/store/groupActions.ts | 26 +++++++ .../src/store/sync/handleGroupUpdate.test.ts | 38 +++++++++ packages/shared/src/store/sync/sync.ts | 3 + 15 files changed, 195 insertions(+), 13 deletions(-) rename packages/shared/src/db/migrations/{0000_unique_texas_twister.sql => 0000_late_micromacro.sql} (99%) diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 0ebb240152..e4b4822e66 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -18,7 +18,8 @@ export async function fetchChangesSince(timestamp: number): Promise< const encodedTimestamp = render('da', da.fromUnix(timestamp)); const response = await scry({ app: 'groups-ui', - path: `/v8/changes/${encodedTimestamp}`, + // v10 changes is v8 with the group blob included + path: `/v10/changes/${encodedTimestamp}`, }); const nodeBusyStatus = await Promise.race([busyResult, timedOutDefault(500)]); diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index 1c45b3d570..0c2307dfc4 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -45,6 +45,32 @@ test('createGroup recovers only when a response body stalls after headers', asyn expect(scryMock).toHaveBeenCalledWith({ app: 'groups', - path: '/v2/ui/groups/~zod/test-group', + path: '/v3/ui/groups/~zod/test-group', + }); +}); + +test('toV1GroupsUpdate maps blob responses to editGroupBlob', async () => { + const { toV1GroupsUpdate } = await import('./groupsApi'); + + expect( + toV1GroupsUpdate({ + flag: '~zod/test-group', + 'r-group': { blob: '{"custom":"payload"}' }, + }) + ).toEqual({ + type: 'editGroupBlob', + groupId: '~zod/test-group', + blob: '{"custom":"payload"}', + }); + + expect( + toV1GroupsUpdate({ + flag: '~zod/test-group', + 'r-group': { blob: null }, + }) + ).toEqual({ + type: 'editGroupBlob', + groupId: '~zod/test-group', + blob: null, }); }); diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index 9850ca49e2..882cb1e861 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -35,6 +35,17 @@ function groupAction4(action: ub.GroupActionV4) { }; } +// group-action-5 carries the same shape as group-action-4 plus the +// %blob variant; only blob pokes use it so older backends keep +// accepting every other action. +function groupAction5(action: ub.GroupActionV4) { + return { + app: 'groups', + mark: 'group-action-5', + json: action, + }; +} + function groupNavigationBatchUpdate( flag: string, navigation: ub.GroupNavigationUpdate @@ -482,17 +493,17 @@ export const createGroup = async ({ }; export const getGroup = async (groupId: string) => { - const path = `/v2/ui/groups/${groupId}`; + const path = `/v3/ui/groups/${groupId}`; const groupData = await scry({ app: 'groups', path }); return toClientGroupV7(groupId, groupData, true); }; export const getGroups = async () => { - // v2 scry path returns v9 format (with admissions/seats) + // v3 scry path returns v11 format (v9 plus the group blob) const groupData = await scry({ app: 'groups', - path: '/v2/groups', + path: '/v3/groups', }); return toClientGroupsV7(groupData, true); }; @@ -526,6 +537,36 @@ export const updateGroupMeta = async ({ ); }; +export const updateGroupBlob = async ({ + groupId, + blob, +}: { + groupId: string; + blob: string | null; +}) => { + return await trackedPoke( + groupAction5({ + group: { + flag: groupId, + 'a-group': { + blob, + }, + }, + }), + // blob responses ride /v3/groups only; they are stripped from v1/v2 + { app: 'groups', path: '/v3/groups' }, + (event) => { + if (!('r-group' in event)) { + return false; + } + + const rGroup = event['r-group']; + return 'blob' in rGroup && event.flag === groupId; + }, + { tag: 'updateGroupBlob' } + ); +}; + export const deleteGroup = async (groupId: string) => { return await trackedPoke( groupAction4({ @@ -1028,6 +1069,12 @@ export type GroupEdit = { meta: db.ClientMeta; }; +export type GroupBlobEdit = { + type: 'editGroupBlob'; + groupId: string; + blob: string | null; +}; + export type GroupChannelAdd = { type: 'addChannel'; channel: db.Channel; @@ -1238,6 +1285,7 @@ export type GroupUpdate = | GroupAdd | GroupDelete | GroupEdit + | GroupBlobEdit | GroupChannelAdd | GroupChannelUpdate | GroupChannelDelete @@ -1309,6 +1357,20 @@ export const subscribeGroups = async ( logger.log('v2 groups subscription unavailable', err); }); + // v3/groups adds group blob (custom payload) updates, which are stripped + // from v1/v2. It can bad-watch-path on older backends; in that case blob + // data still arrives via init/group sync. + void subscribe( + { app: 'groups', path: '/v3/groups' }, + (rawEvent) => { + if ('r-group' in rawEvent && 'blob' in rawEvent['r-group']) { + handleRawGroupsEvent(rawEvent); + } + } + ).catch((err) => { + logger.log('v3 groups subscription unavailable', err); + }); + // Subscribe to v1/foreigns for foreign group updates void subscribe( { app: 'groups', path: '/v1/foreigns' }, @@ -1350,6 +1412,15 @@ export const toV1GroupsUpdate = ( }; } + // Handle custom payload updates + if ('blob' in event) { + return { + type: 'editGroupBlob', + blob: event.blob, + groupId, + }; + } + // Handle role operations if ('role' in event) { const roleData = event.role; @@ -1810,6 +1881,7 @@ export function toClientGroupV7( roles, privacy: group.admissions.privacy, ...toClientGroupMeta(group.meta), + blob: group.blob ?? null, haveInvite: isJoined ? false : undefined, haveRequestedInvite: isJoined ? false : undefined, currentUserIsMember: isJoined, diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index 822f4c6c2a..d04cb248a2 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -34,7 +34,8 @@ type InitDataOptions = { export const getInitData = async () => { const response = await scry({ app: 'groups-ui', - path: '/v7/init', + // v9 init is v7 with the group blob included (activity stays v8) + path: '/v9/init', }); logger.crumb('got init data from api'); diff --git a/packages/api/src/types/models.ts b/packages/api/src/types/models.ts index 9f67b3a74f..c3774737c9 100644 --- a/packages/api/src/types/models.ts +++ b/packages/api/src/types/models.ts @@ -159,6 +159,7 @@ export interface Group extends WithId { iconImageColor?: string | null; coverImage?: string | null; coverImageColor?: string | null; + blob?: string | null; privacy?: GroupPrivacy | null; haveInvite?: boolean | null; haveRequestedInvite?: boolean | null; diff --git a/packages/api/src/urbit/groups.ts b/packages/api/src/urbit/groups.ts index 193bd1da3e..ebce751bff 100644 --- a/packages/api/src/urbit/groups.ts +++ b/packages/api/src/urbit/groups.ts @@ -348,6 +348,7 @@ export interface V1GroupResponse { export type GroupResponseData = | { create: GroupV7 } | { meta: GroupMeta } + | { blob: string | null } | { entry: GroupResponseEntry } | { seat: { ships: string[]; 'r-seat': GroupResponseSeat } } | { role: { roles: string[]; 'r-role': GroupResponseRole } } @@ -519,6 +520,7 @@ export interface AdmissionRequest { export interface GroupV7 { meta: GroupMeta; + blob?: string | null; admissions: Admissions; seats: Record; // fleet in v6 is now seats in v7, uses 'roles' not 'sects' roles: Record; // v7 roles ARE the metadata, not Cabals with nested meta @@ -642,6 +644,7 @@ export type GroupActionV4 = export type GroupAction = | { meta: GroupMeta } + | { blob: string | null } | { entry: GroupEntryAction } | { seat: { ships: string[]; 'a-seat': GroupSeatAction } } | { role: { roles: string[]; 'a-role': GroupRoleAction } } diff --git a/packages/shared/src/db/migrations/0000_unique_texas_twister.sql b/packages/shared/src/db/migrations/0000_late_micromacro.sql similarity index 99% rename from packages/shared/src/db/migrations/0000_unique_texas_twister.sql rename to packages/shared/src/db/migrations/0000_late_micromacro.sql index 5278485a6e..fe05fad42a 100644 --- a/packages/shared/src/db/migrations/0000_unique_texas_twister.sql +++ b/packages/shared/src/db/migrations/0000_late_micromacro.sql @@ -299,6 +299,7 @@ CREATE TABLE `groups` ( `cover_image_color` text, `title` text, `description` text, + `blob` text, `privacy` text, `have_invite` integer, `have_requested_invite` integer, @@ -528,4 +529,4 @@ CREATE TABLE `volume_settings` ( `level` text NOT NULL ); --> statement-breakpoint -CREATE INDEX `volume_settings_item_id_index` ON `volume_settings` (`item_id`); +CREATE INDEX `volume_settings_item_id_index` ON `volume_settings` (`item_id`); \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/0000_snapshot.json b/packages/shared/src/db/migrations/meta/0000_snapshot.json index 09f639dfc1..8abe002a16 100644 --- a/packages/shared/src/db/migrations/meta/0000_snapshot.json +++ b/packages/shared/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "e7e28ea4-831e-4c18-aba2-315772c3c581", + "id": "aacfd956-caa2-4247-818b-5a6706f21ca8", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_event_contact_group_pins": { @@ -2017,6 +2017,13 @@ "notNull": false, "autoincrement": false }, + "blob": { + "name": "blob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "privacy": { "name": "privacy", "type": "text", @@ -3563,4 +3570,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index d092ac4bbb..46692b6045 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -5,9 +5,9 @@ { "idx": 0, "version": "6", - "when": 1782835292031, - "tag": "0000_unique_texas_twister", + "when": 1785356120998, + "tag": "0000_late_micromacro", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index f3ca13ba70..c08f7cce45 100644 --- a/packages/shared/src/db/migrations/migrations.js +++ b/packages/shared/src/db/migrations/migrations.js @@ -1,7 +1,7 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import journal from './meta/_journal.json'; -import m0000 from './0000_unique_texas_twister.sql'; +import m0000 from './0000_late_micromacro.sql'; export default { journal, @@ -9,3 +9,4 @@ import m0000 from './0000_unique_texas_twister.sql'; m0000 } } + \ No newline at end of file diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 0c9809eb2c..996785b273 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -1408,6 +1408,7 @@ export const insertGroups = createWriteQuery( $groups.coverImage, $groups.title, $groups.description, + $groups.blob, $groups.privacy, $groups.joinStatus, $groups.currentUserIsMember, diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 909adf95ac..6296778514 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -511,6 +511,7 @@ export type GroupPrivacy = ApiGroupPrivacy; export const groups = sqliteTable('groups', { id: text('id').primaryKey(), ...metaFields, + blob: text('blob'), privacy: text('privacy').$type(), haveInvite: boolean('have_invite'), haveRequestedInvite: boolean('have_requested_invite'), diff --git a/packages/shared/src/store/groupActions.ts b/packages/shared/src/store/groupActions.ts index 153c924adc..928cbdd405 100644 --- a/packages/shared/src/store/groupActions.ts +++ b/packages/shared/src/store/groupActions.ts @@ -490,6 +490,32 @@ export async function updateGroupMeta( } } +export async function updateGroupBlob( + group: db.Group, + blob: string | null, + config?: { shouldThrow?: boolean } +) { + logger.log('updating group blob', group.id); + + const existingGroup = await db.getGroup({ id: group.id }); + + // optimistic update + await db.updateGroup({ id: group.id, blob }); + + try { + await api.updateGroupBlob({ groupId: group.id, blob }); + } catch (e) { + logger.error('Failed to update group blob', e); + // rollback optimistic update + if (existingGroup) { + await db.updateGroup({ id: group.id, blob: existingGroup.blob ?? null }); + } + if (config?.shouldThrow) { + throw e; + } + } +} + export async function deleteGroup(group: db.Group) { logger.log('deleting group', group.id); logger.trackEvent( diff --git a/packages/shared/src/store/sync/handleGroupUpdate.test.ts b/packages/shared/src/store/sync/handleGroupUpdate.test.ts index a0ecdd5d1b..01cbb02abf 100644 --- a/packages/shared/src/store/sync/handleGroupUpdate.test.ts +++ b/packages/shared/src/store/sync/handleGroupUpdate.test.ts @@ -287,3 +287,41 @@ test('addChannel propagates group sync failures for normal channels', async () = getGroup.mockRestore(); } }); + +// `editGroupBlob` carries the group's opaque custom payload; the handler +// writes it to the group row verbatim and null clears it. +test('editGroupBlob writes and clears the group blob column', async () => { + const groupId = '~bus/blob-group'; + + const client = getClient(); + if (!client) throw new Error('test db client not initialized'); + + await client.insert(schema.groups).values({ + id: groupId, + currentUserIsMember: true, + currentUserIsHost: false, + hostUserId: '~bus', + }); + + const blob = '{"custom":"payload"}'; + await batchEffects('test:editGroupBlob', async (ctx) => { + await handleGroupUpdate({ type: 'editGroupBlob', groupId, blob }, ctx); + }); + + let group = await client.query.groups.findFirst({ + where: $.eq(schema.groups.id, groupId), + }); + expect(group?.blob).toBe(blob); + + await batchEffects('test:editGroupBlob-clear', async (ctx) => { + await handleGroupUpdate( + { type: 'editGroupBlob', groupId, blob: null }, + ctx + ); + }); + + group = await client.query.groups.findFirst({ + where: $.eq(schema.groups.id, groupId), + }); + expect(group?.blob).toBeNull(); +}); diff --git a/packages/shared/src/store/sync/sync.ts b/packages/shared/src/store/sync/sync.ts index 5cd17635d9..6ae8470cfc 100644 --- a/packages/shared/src/store/sync/sync.ts +++ b/packages/shared/src/store/sync/sync.ts @@ -1002,6 +1002,9 @@ export async function handleGroupUpdate( case 'editGroup': await db.updateGroup({ id: update.groupId, ...update.meta }, ctx); break; + case 'editGroupBlob': + await db.updateGroup({ id: update.groupId, blob: update.blob }, ctx); + break; case 'deleteGroup': await db.deletePinnedItem({ itemId: update.groupId }, ctx); await db.deleteGroup(update.groupId, ctx); From 55debe178082d2c6afb7970bc2e45c9672712812 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 30 Jul 2026 11:46:34 -0500 Subject: [PATCH 03/25] api: use static import in groupsApi blob test CI's tsc config rejects dynamic import() (TS1323). Co-Authored-By: Claude Fable 5 --- packages/api/src/client/groupsApi.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index 0c2307dfc4..c67da233ae 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -2,7 +2,7 @@ import { type Mock, beforeEach, expect, test, vi } from 'vitest'; import { ThreadResponseBodyError } from '../http-api'; import type { Group } from '../types/models'; -import { createGroup } from './groupsApi'; +import { createGroup, toV1GroupsUpdate } from './groupsApi'; import { scry, thread } from './urbit'; vi.mock('./urbit', async () => { @@ -49,9 +49,7 @@ test('createGroup recovers only when a response body stalls after headers', asyn }); }); -test('toV1GroupsUpdate maps blob responses to editGroupBlob', async () => { - const { toV1GroupsUpdate } = await import('./groupsApi'); - +test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { expect( toV1GroupsUpdate({ flag: '~zod/test-group', From a13c04e60738ffc7762a42135304d6799073aa91 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 30 Jul 2026 11:58:28 -0500 Subject: [PATCH 04/25] api: fall back to v2 group surfaces on old backends getGroups/getGroup, /v9/init, and /v10/changes 404 against backends that haven't shipped the group blob; retry the v2/v7/v8 paths, whose responses are identical minus the blob. Softens the hard cutover for not-yet-OTA'd nodes and lets the bot e2e harness run against its archived ships. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/changesApi.ts | 14 ++++++++-- packages/api/src/client/groupsApi.test.ts | 20 +++++++++++++-- packages/api/src/client/groupsApi.ts | 31 +++++++++++++++++------ packages/api/src/client/initApi.ts | 12 +++++++-- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index e4b4822e66..1ab9362775 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -6,7 +6,7 @@ import { toClientUnreads } from './activityApi'; import { contactToClientProfile } from './contactsApi'; import { toClientGroupsV7 } from './groupsApi'; import { toPostsData } from './postsApi'; -import { checkIsNodeBusyWithHints, scry } from './urbit'; +import { BadResponseError, checkIsNodeBusyWithHints, scry } from './urbit'; export async function fetchChangesSince(timestamp: number): Promise< db.ChangesResult & { @@ -16,10 +16,20 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); + // v10 changes is v8 with the group blob included. Backends that + // haven't shipped the blob yet 404 on it; fall back to v8, whose + // response is identical minus the group blob. const response = await scry({ app: 'groups-ui', - // v10 changes is v8 with the group blob included path: `/v10/changes/${encodedTimestamp}`, + }).catch((err) => { + if (err instanceof BadResponseError && err.status === 404) { + return scry({ + app: 'groups-ui', + path: `/v8/changes/${encodedTimestamp}`, + }); + } + throw err; }); const nodeBusyStatus = await Promise.race([busyResult, timedOutDefault(500)]); diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index c67da233ae..a832873b1f 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -2,8 +2,8 @@ import { type Mock, beforeEach, expect, test, vi } from 'vitest'; import { ThreadResponseBodyError } from '../http-api'; import type { Group } from '../types/models'; -import { createGroup, toV1GroupsUpdate } from './groupsApi'; -import { scry, thread } from './urbit'; +import { createGroup, getGroups, toV1GroupsUpdate } from './groupsApi'; +import { BadResponseError, scry, thread } from './urbit'; vi.mock('./urbit', async () => { const actual = await vi.importActual('./urbit'); @@ -72,3 +72,19 @@ test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { blob: null, }); }); + +test('getGroups falls back to the v2 scry when v3 is unavailable', async () => { + scryMock.mockRejectedValueOnce(new BadResponseError(404, 'missing')); + scryMock.mockResolvedValueOnce({}); + + await expect(getGroups()).resolves.toEqual([]); + + expect(scryMock).toHaveBeenNthCalledWith(1, { + app: 'groups', + path: '/v3/groups', + }); + expect(scryMock).toHaveBeenNthCalledWith(2, { + app: 'groups', + path: '/v2/groups', + }); +}); diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index 882cb1e861..5f0c96bca4 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -492,19 +492,34 @@ export const createGroup = async ({ } }; -export const getGroup = async (groupId: string) => { - const path = `/v3/ui/groups/${groupId}`; +// v3 group scries return v11 format (v9 plus the group blob). Backends +// that haven't shipped the blob yet 404 on them; fall back to the v2 +// path, whose response is identical minus `blob`. +async function scryGroupsWithV2Fallback(v3Path: string, v2Path: string) { + try { + return await scry({ app: 'groups', path: v3Path }); + } catch (err) { + if (err instanceof BadResponseError && err.status === 404) { + logger.log('v3 groups scry unavailable, falling back to v2', v3Path); + return await scry({ app: 'groups', path: v2Path }); + } + throw err; + } +} - const groupData = await scry({ app: 'groups', path }); +export const getGroup = async (groupId: string) => { + const groupData = await scryGroupsWithV2Fallback( + `/v3/ui/groups/${groupId}`, + `/v2/ui/groups/${groupId}` + ); return toClientGroupV7(groupId, groupData, true); }; export const getGroups = async () => { - // v3 scry path returns v11 format (v9 plus the group blob) - const groupData = await scry({ - app: 'groups', - path: '/v3/groups', - }); + const groupData = await scryGroupsWithV2Fallback( + '/v3/groups', + '/v2/groups' + ); return toClientGroupsV7(groupData, true); }; diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index d04cb248a2..29dd116c98 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -10,7 +10,7 @@ import { toClientPinnedItems, } from './groupsApi'; import { toClientHiddenPosts } from './postsApi'; -import { getCurrentUserId, scry } from './urbit'; +import { BadResponseError, getCurrentUserId, scry } from './urbit'; const logger = createDevLogger('initApi', false); @@ -32,10 +32,18 @@ type InitDataOptions = { }; export const getInitData = async () => { + // v9 init is v7 with the group blob included (activity stays v8). + // Backends that haven't shipped the blob yet 404 on it; fall back to + // v7, whose response is identical minus the group blob. const response = await scry({ app: 'groups-ui', - // v9 init is v7 with the group blob included (activity stays v8) path: '/v9/init', + }).catch((err) => { + if (err instanceof BadResponseError && err.status === 404) { + logger.log('v9 init unavailable, falling back to v7'); + return scry({ app: 'groups-ui', path: '/v7/init' }); + } + throw err; }); logger.crumb('got init data from api'); From 46ea9d990680bc90757d89010374e86f0d05e3e6 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Thu, 30 Jul 2026 17:23:58 -0500 Subject: [PATCH 05/25] api/shared: retain group blob through v1 create upserts Joining a group whose blob predates the join emits %create on both lanes: v3 carries the blob, v1 strips it. Process v3 %create in the blob subscription and treat a missing blob key as "don't touch" in the group upsert (explicit null still clears), so the v1 copy that lands second no longer clobbers the blob to null. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/groupsApi.ts | 17 +++++-- packages/shared/src/db/queries.ts | 5 ++- .../src/store/sync/handleGroupUpdate.test.ts | 45 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index 5f0c96bca4..d6432786af 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -1373,12 +1373,18 @@ export const subscribeGroups = async ( }); // v3/groups adds group blob (custom payload) updates, which are stripped - // from v1/v2. It can bad-watch-path on older backends; in that case blob - // data still arrives via init/group sync. + // from v1/v2. %create rides v3 with the blob intact while v1's copy is + // blob-stripped, so process the v3 copy too — it arrives first, and the + // later v1 upsert carries no blob key so it leaves the column untouched. + // The subscription can bad-watch-path on older backends; in that case + // blob data still arrives via init/group sync. void subscribe( { app: 'groups', path: '/v3/groups' }, (rawEvent) => { - if ('r-group' in rawEvent && 'blob' in rawEvent['r-group']) { + if ( + 'r-group' in rawEvent && + ('blob' in rawEvent['r-group'] || 'create' in rawEvent['r-group']) + ) { handleRawGroupsEvent(rawEvent); } } @@ -1896,7 +1902,10 @@ export function toClientGroupV7( roles, privacy: group.admissions.privacy, ...toClientGroupMeta(group.meta), - blob: group.blob ?? null, + // undefined means the source surface predates the blob (v1 create, + // v2 fallback scries) — upserts then leave any locally-known blob + // alone. An explicit null is an authoritative clear. + blob: group.blob, haveInvite: isJoined ? false : undefined, haveRequestedInvite: isJoined ? false : undefined, currentUserIsMember: isJoined, diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 996785b273..35755ebd60 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -1408,7 +1408,10 @@ export const insertGroups = createWriteQuery( $groups.coverImage, $groups.title, $groups.description, - $groups.blob, + // blob-less surfaces (v1 create, v2 fallback scries) omit + // the key entirely; only overwrite when the source carried + // it, so an explicit null still clears. + ...(group.blob !== undefined ? [$groups.blob] : []), $groups.privacy, $groups.joinStatus, $groups.currentUserIsMember, diff --git a/packages/shared/src/store/sync/handleGroupUpdate.test.ts b/packages/shared/src/store/sync/handleGroupUpdate.test.ts index 01cbb02abf..6bcf241d14 100644 --- a/packages/shared/src/store/sync/handleGroupUpdate.test.ts +++ b/packages/shared/src/store/sync/handleGroupUpdate.test.ts @@ -325,3 +325,48 @@ test('editGroupBlob writes and clears the group blob column', async () => { }); expect(group?.blob).toBeNull(); }); + +// v1 %create responses are blob-stripped (blob undefined) while v3 carries +// it; re-inserting from a blob-less surface must not clobber a blob learned +// from the v3 lane, and an explicit null must still clear it. +test('addGroup preserves the blob on blob-less upserts and clears on null', async () => { + const groupId = '~bus/blob-upsert-group'; + const base = { + id: groupId, + currentUserIsMember: true, + currentUserIsHost: false, + hostUserId: '~bus', + }; + + const client = getClient(); + if (!client) throw new Error('test db client not initialized'); + + const blob = '{"custom":"payload"}'; + await batchEffects('test:addGroup-blob', async (ctx) => { + await handleGroupUpdate( + { type: 'addGroup', group: { ...base, blob } }, + ctx + ); + }); + + // blob-less upsert (v1 create shape) leaves the blob alone + await batchEffects('test:addGroup-no-blob', async (ctx) => { + await handleGroupUpdate({ type: 'addGroup', group: { ...base } }, ctx); + }); + let group = await client.query.groups.findFirst({ + where: $.eq(schema.groups.id, groupId), + }); + expect(group?.blob).toBe(blob); + + // explicit null clears + await batchEffects('test:addGroup-null-blob', async (ctx) => { + await handleGroupUpdate( + { type: 'addGroup', group: { ...base, blob: null } }, + ctx + ); + }); + group = await client.query.groups.findFirst({ + where: $.eq(schema.groups.id, groupId), + }); + expect(group?.blob).toBeNull(); +}); From b108ad2b75936f8eef871b176b10ef8346594377 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 09:49:21 -0500 Subject: [PATCH 06/25] groups: order the group-action-5 poke arm above group-action-4 Keep the current action mark first in the dispatch; the deprecated arms read as a descending tail. Co-Authored-By: Claude Fable 5 --- desk/app/groups.hoon | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index ea483f395c..6f24bd0da2 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -331,8 +331,8 @@ se-abet:se-c-leave:se-core == :: - %group-action-4 - =+ !<(=a-groups:v8:gv vase) + %group-action-5 + =+ !<(=a-groups:v11:gv vase) ?> from-self ?- -.a-groups %group @@ -348,8 +348,8 @@ go-abet:(go-leave:group-core &) == :: - %group-action-5 - =+ !<(=a-groups:v11:gv vase) + %group-action-4 + =+ !<(=a-groups:v8:gv vase) ?> from-self ?- -.a-groups %group From 4bbbf62ee411d2cabfedd0eec4c3b30a3f258a0f Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 10:05:11 -0500 Subject: [PATCH 07/25] groups: file group-ui-3 with the per-group marks %group-ui-3 is the singular per-group mark (/x/v3/ui/groups/$/$) but sat after %groups-ui-2, reading as though it belonged to the plural groups-ui family. Move it beside %group-3. The strict list is gassed into a set, so ordering is cosmetic. Co-Authored-By: Claude Fable 5 --- desk/app/groups.hoon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 6f24bd0da2..008ca39556 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -28,6 +28,7 @@ %group-1 %group-2 %group-3 + %group-ui-3 %groups %groups-1 %groups-2 @@ -35,7 +36,6 @@ %groups-ui %groups-ui-1 %groups-ui-2 - %group-ui-3 %group-changed-groups-1 %group-changed-groups-2 %group-changed-groups-3 From b7952647739405af4ee020fb214687bd3fa008ec Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 10:26:04 -0500 Subject: [PATCH 08/25] groups: freeze the singular group-ui marks %group-ui, -1 and -2 were never in the discipline strict list, though they existed and were rail-registered when the list was written. That left the guardrail protecting the plural groups-ui family (which has no consumer) while the singular family read by the frontend getGroup and ted/group/create* went unchecked. Add them alongside the new %group-ui-3. Adding is safe on upgrade: check-marks iterates the stored mark map, so a newly listed mark isn't compared until the load after it is first snapshotted. Co-Authored-By: Claude Fable 5 --- desk/app/groups.hoon | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 008ca39556..246b896dbd 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -28,6 +28,9 @@ %group-1 %group-2 %group-3 + %group-ui + %group-ui-1 + %group-ui-2 %group-ui-3 %groups %groups-1 From 6017cf9835c9347a45889bf6f8f33786317e5128 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 11:01:31 -0500 Subject: [PATCH 09/25] api: name the init and changes types for the versions we scry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changesApi scried /v10/changes while annotating the result ChangesV8, and initApi scried /v9/init as GroupsInit7. Both were structurally right (the blob rides inside GroupV7) but read a version behind. Follow the convention already in urbit/ui.ts — interface named for the newest version, older ones aliased to it — so the primary types become ChangesV10 and GroupsInit9 and the fallback branches keep the older names that match the paths they actually scry. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/changesApi.ts | 9 ++++----- packages/api/src/client/initApi.ts | 4 ++-- packages/api/src/urbit/ui.ts | 12 ++++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 1ab9362775..655a24c2f5 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -16,10 +16,9 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); - // v10 changes is v8 with the group blob included. Backends that - // haven't shipped the blob yet 404 on it; fall back to v8, whose - // response is identical minus the group blob. - const response = await scry({ + // Backends that haven't shipped the group blob yet 404 on v10; fall + // back to v8, whose response is identical minus the blob. + const response = await scry({ app: 'groups-ui', path: `/v10/changes/${encodedTimestamp}`, }).catch((err) => { @@ -39,7 +38,7 @@ export async function fetchChangesSince(timestamp: number): Promise< return { ...changes, ...nodeBusyStatus }; } -export function parseChanges(input: ub.ChangesV8): db.ChangesResult { +export function parseChanges(input: ub.ChangesV10): db.ChangesResult { const groups = toClientGroupsV7(input.groups, true); const channelPosts = Object.entries(input.channels).flatMap( diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index 29dd116c98..78de18d08a 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -35,7 +35,7 @@ export const getInitData = async () => { // v9 init is v7 with the group blob included (activity stays v8). // Backends that haven't shipped the blob yet 404 on it; fall back to // v7, whose response is identical minus the group blob. - const response = await scry({ + const response = await scry({ app: 'groups-ui', path: '/v9/init', }).catch((err) => { @@ -80,7 +80,7 @@ function extractJoinedGroupChannelsFromV7Groups( } export const toInitData = ( - response: ub.GroupsInit7, + response: ub.GroupsInit9, options: InitDataOptions ): InitData => { logger.crumb('converting init data to client data'); diff --git a/packages/api/src/urbit/ui.ts b/packages/api/src/urbit/ui.ts index 110beb86e7..2b32a83471 100644 --- a/packages/api/src/urbit/ui.ts +++ b/packages/api/src/urbit/ui.ts @@ -4,7 +4,8 @@ import { ContactBookEntry } from './contact'; import { ChatHeadsResponse, DMInit2, Writs } from './dms'; import { Foreigns, GroupV7, Groups, GroupsV7 } from './groups'; -export interface GroupsInit7 { +// v9 init is v7 plus the group blob, which rides along inside GroupV7. +export interface GroupsInit9 { groups: Record; foreigns: Foreigns; channel: { @@ -16,7 +17,8 @@ export interface GroupsInit7 { chat: DMInit2; } -export type GroupsInit6 = GroupsInit7; +export type GroupsInit7 = GroupsInit9; +export type GroupsInit6 = GroupsInit9; export interface CombinedHeads { dms: ChatHeadsResponse; @@ -31,7 +33,8 @@ export interface Changes { activity: Activity; } -export interface ChangesV8 { +// v10 changes is v8 plus the group blob, which rides along inside GroupV7. +export interface ChangesV10 { groups: GroupsV7; channels: Record; chat: Record; @@ -39,7 +42,8 @@ export interface ChangesV8 { activity: Activity; } -export type ChangesV7 = ChangesV8; +export type ChangesV8 = ChangesV10; +export type ChangesV7 = ChangesV10; export interface PostsInit { channels: Record; From e8bfb47cc835822ed75a9fdf9527a2d2953cbd10 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 11:30:24 -0500 Subject: [PATCH 10/25] api: subscribe to one group lane instead of three The client opened /v1 unfiltered plus filtered /v2 and /v3 concurrently, so adding %create to the v3 filter meant every create was handled twice and the blob upsert guard became load-bearing mitigation for that collision. r-group:v11 is a superset of v9 and v10, so subscribe to /v3/groups alone and handle everything from it. Only when that watch path is unavailable do we open the legacy v1 + v2 pair. No event is handled twice, and the undefined-vs-null blob distinction goes back to being defensive rather than propping up a race. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/groupsApi.test.ts | 37 +++++++++- packages/api/src/client/groupsApi.ts | 74 +++++++++---------- .../src/store/sync/handleGroupUpdate.test.ts | 7 +- 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index a832873b1f..47ed8556e6 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -2,15 +2,21 @@ import { type Mock, beforeEach, expect, test, vi } from 'vitest'; import { ThreadResponseBodyError } from '../http-api'; import type { Group } from '../types/models'; -import { createGroup, getGroups, toV1GroupsUpdate } from './groupsApi'; -import { BadResponseError, scry, thread } from './urbit'; +import { + createGroup, + getGroups, + subscribeGroups, + toV1GroupsUpdate, +} from './groupsApi'; +import { BadResponseError, scry, subscribe, thread } from './urbit'; vi.mock('./urbit', async () => { const actual = await vi.importActual('./urbit'); - return { ...actual, scry: vi.fn(), thread: vi.fn() }; + return { ...actual, scry: vi.fn(), thread: vi.fn(), subscribe: vi.fn() }; }); const scryMock = scry as unknown as Mock; +const subscribeMock = subscribe as unknown as Mock; const threadMock = thread as unknown as Mock; const group: Group = { @@ -88,3 +94,28 @@ test('getGroups falls back to the v2 scry when v3 is unavailable', async () => { path: '/v2/groups', }); }); + +// Group updates must ride a single lane so nothing is handled twice; the +// older lanes are only opened when the v3 watch path is unavailable. +test('subscribeGroups subscribes to v3 alone, falling back to v1+v2', async () => { + subscribeMock.mockResolvedValue(1); + await subscribeGroups(() => {}); + const paths = subscribeMock.mock.calls.map(([endpoint]) => endpoint.path); + expect(paths).toContain('/v3/groups'); + expect(paths).not.toContain('/v1/groups'); + expect(paths).not.toContain('/v2/groups'); + + subscribeMock.mockClear(); + subscribeMock.mockImplementation(({ path }: { path: string }) => + path === '/v3/groups' + ? Promise.reject(new Error('bad-watch-path')) + : Promise.resolve(1) + ); + await subscribeGroups(() => {}); + const fallbackPaths = subscribeMock.mock.calls.map( + ([endpoint]) => endpoint.path + ); + expect(fallbackPaths).toContain('/v3/groups'); + expect(fallbackPaths).toContain('/v1/groups'); + expect(fallbackPaths).toContain('/v2/groups'); +}); diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index d6432786af..29353f9c2f 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -1349,48 +1349,42 @@ export const subscribeGroups = async ( } }; - // v1/groups is the baseline stream for group updates. Older backends do not - // expose /v2/groups, so keep normal r-group updates on v1. - void subscribe( - { app: 'groups', path: '/v1/groups' }, - (rawEvent) => { - handleRawGroupsEvent(rawEvent); - } - ); - - // v2/groups adds active-channel membership deltas for third-party channel - // hosts like %notes. It can bad-watch-path on older backends; in that case - // v1 still carries normal group updates and init/group sync cover membership. - void subscribe( - { app: 'groups', path: '/v2/groups' }, - (rawEvent) => { - if ('r-group' in rawEvent && 'active-channel' in rawEvent['r-group']) { + // r-group:v11 is a superset of v9 and v10 — every group update, plus the + // active-channel deltas that used to require v2, plus the blob — so a + // single lane carries everything and no event is handled twice. Backends + // without the blob bad-watch-path here and fall back to the v1 + v2 pair, + // which between them cover the same ground minus the blob. + try { + await subscribe( + { app: 'groups', path: '/v3/groups' }, + (rawEvent) => { handleRawGroupsEvent(rawEvent); } - } - ).catch((err) => { - logger.log('v2 groups subscription unavailable', err); - }); + ); + } catch (err) { + logger.log('v3 groups subscription unavailable, falling back to v1', err); - // v3/groups adds group blob (custom payload) updates, which are stripped - // from v1/v2. %create rides v3 with the blob intact while v1's copy is - // blob-stripped, so process the v3 copy too — it arrives first, and the - // later v1 upsert carries no blob key so it leaves the column untouched. - // The subscription can bad-watch-path on older backends; in that case - // blob data still arrives via init/group sync. - void subscribe( - { app: 'groups', path: '/v3/groups' }, - (rawEvent) => { - if ( - 'r-group' in rawEvent && - ('blob' in rawEvent['r-group'] || 'create' in rawEvent['r-group']) - ) { + void subscribe( + { app: 'groups', path: '/v1/groups' }, + (rawEvent) => { handleRawGroupsEvent(rawEvent); } - } - ).catch((err) => { - logger.log('v3 groups subscription unavailable', err); - }); + ); + + // active-channel deltas for third-party channel hosts like %notes exist + // only from v2 on; where even that is missing, init/group sync cover + // membership. + void subscribe( + { app: 'groups', path: '/v2/groups' }, + (rawEvent) => { + if ('r-group' in rawEvent && 'active-channel' in rawEvent['r-group']) { + handleRawGroupsEvent(rawEvent); + } + } + ).catch((fallbackErr) => { + logger.log('v2 groups subscription unavailable', fallbackErr); + }); + } // Subscribe to v1/foreigns for foreign group updates void subscribe( @@ -1902,9 +1896,9 @@ export function toClientGroupV7( roles, privacy: group.admissions.privacy, ...toClientGroupMeta(group.meta), - // undefined means the source surface predates the blob (v1 create, - // v2 fallback scries) — upserts then leave any locally-known blob - // alone. An explicit null is an authoritative clear. + // Pass undefined through rather than coercing to null: a surface that + // predates the blob (the v1/v2 fallbacks) says nothing about it, so + // upserts leave any known blob alone. An explicit null is a real clear. blob: group.blob, haveInvite: isJoined ? false : undefined, haveRequestedInvite: isJoined ? false : undefined, diff --git a/packages/shared/src/store/sync/handleGroupUpdate.test.ts b/packages/shared/src/store/sync/handleGroupUpdate.test.ts index 6bcf241d14..346a2d75d5 100644 --- a/packages/shared/src/store/sync/handleGroupUpdate.test.ts +++ b/packages/shared/src/store/sync/handleGroupUpdate.test.ts @@ -326,9 +326,10 @@ test('editGroupBlob writes and clears the group blob column', async () => { expect(group?.blob).toBeNull(); }); -// v1 %create responses are blob-stripped (blob undefined) while v3 carries -// it; re-inserting from a blob-less surface must not clobber a blob learned -// from the v3 lane, and an explicit null must still clear it. +// A payload that carries no blob key says nothing about the blob, so an +// upsert from it must leave a known blob alone; an explicit null is a real +// clear and must still land. Group updates all arrive on one lane now, so +// this guards future blob-less surfaces rather than a live race. test('addGroup preserves the blob on blob-less upserts and clears on null', async () => { const groupId = '~bus/blob-upsert-group'; const base = { From 35c8461ba6f9d023f9aaedc2fdda5f54f2ce0f3a Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 11:40:40 -0500 Subject: [PATCH 11/25] api: give group-action-5 its own type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groupAction5 was annotated GroupActionV4, and because blob had been added to the shared inner GroupAction type, all 25 groupAction4 call sites also accepted a blob action — which mark group-action-4 cannot carry, since its dejs is a-groups:v8. That typechecked and would have nacked at runtime. Parameterize the envelope, which is identical at both versions, on the a-group payload: GroupActionV4 keeps the blob-free GroupAction while GroupActionV5 adds it. GroupActionV4 now rejects a blob action at compile time. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/groupsApi.ts | 2 +- packages/api/src/urbit/groups.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index 29353f9c2f..ee284b80a2 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -38,7 +38,7 @@ function groupAction4(action: ub.GroupActionV4) { // group-action-5 carries the same shape as group-action-4 plus the // %blob variant; only blob pokes use it so older backends keep // accepting every other action. -function groupAction5(action: ub.GroupActionV4) { +function groupAction5(action: ub.GroupActionV5) { return { app: 'groups', mark: 'group-action-5', diff --git a/packages/api/src/urbit/groups.ts b/packages/api/src/urbit/groups.ts index ebce751bff..26d45fac0e 100644 --- a/packages/api/src/urbit/groups.ts +++ b/packages/api/src/urbit/groups.ts @@ -627,12 +627,13 @@ export interface GroupInviteAction { note: Story | null; } -// v8/v9 Group Actions (a-groups) -export type GroupActionV4 = +// Group actions (a-groups). The outer envelope is the same at every version; +// only the inner a-group payload gains variants, so parameterize on it. +export type GroupActionEnvelope = | { group: { flag: string; - 'a-group': GroupAction; + 'a-group': TGroupAction; }; } | { @@ -642,9 +643,17 @@ export type GroupActionV4 = leave: string; // flag }; +// a-groups:v8, poked at mark group-action-4 +export type GroupActionV4 = GroupActionEnvelope; + +// a-groups:v11, poked at mark group-action-5 — v4 plus the group blob. Kept +// separate because group-action-4's dejs has no blob key and would nack it. +export type GroupActionV5 = GroupActionEnvelope; + +export type GroupActionV5Data = GroupAction | { blob: string | null }; + export type GroupAction = | { meta: GroupMeta } - | { blob: string | null } | { entry: GroupEntryAction } | { seat: { ships: string[]; 'a-seat': GroupSeatAction } } | { role: { roles: string[]; 'a-role': GroupRoleAction } } From ab016dba8080a01ec6c5f69684334d1d7e26581a Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 12:14:55 -0500 Subject: [PATCH 12/25] api: drop the old-backend fallbacks, hard cutover to v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desk ships ahead of the app, so a client newer than its backend is not a state we support; the v2/v7/v8 fallbacks protected a direction that cannot happen and had no precedent here (prior init bumps were bare path swaps). Removing them also deletes the one code path that was never verified against a real old ship. The backend keeps its v0/v1/v2 downgrade lanes untouched — those serve old clients on a new desk, which is the normal state. Also repoints the five tracked group pokes from /v2/groups to /v3/groups. A trackedPoke only resolves when its watch endpoint's own subscription receives an event, so after collapsing to one lane they were watching a path nothing subscribes to and every group mutation would have hung 20s then thrown. Adds a test that asserts tracked pokes watch a subscribed lane. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/changesApi.ts | 13 +--- packages/api/src/client/groupsApi.test.ts | 69 +++++++++--------- packages/api/src/client/groupsApi.ts | 85 ++++++----------------- packages/api/src/client/initApi.ts | 10 +-- 4 files changed, 61 insertions(+), 116 deletions(-) diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 655a24c2f5..2ee8a1a41c 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -6,7 +6,7 @@ import { toClientUnreads } from './activityApi'; import { contactToClientProfile } from './contactsApi'; import { toClientGroupsV7 } from './groupsApi'; import { toPostsData } from './postsApi'; -import { BadResponseError, checkIsNodeBusyWithHints, scry } from './urbit'; +import { checkIsNodeBusyWithHints, scry } from './urbit'; export async function fetchChangesSince(timestamp: number): Promise< db.ChangesResult & { @@ -16,19 +16,10 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); - // Backends that haven't shipped the group blob yet 404 on v10; fall - // back to v8, whose response is identical minus the blob. + // v10 changes is v8 plus the group blob, which rides inside GroupV7. const response = await scry({ app: 'groups-ui', path: `/v10/changes/${encodedTimestamp}`, - }).catch((err) => { - if (err instanceof BadResponseError && err.status === 404) { - return scry({ - app: 'groups-ui', - path: `/v8/changes/${encodedTimestamp}`, - }); - } - throw err; }); const nodeBusyStatus = await Promise.race([busyResult, timedOutDefault(500)]); diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index 47ed8556e6..cd9bbe688f 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -4,19 +4,26 @@ import { ThreadResponseBodyError } from '../http-api'; import type { Group } from '../types/models'; import { createGroup, - getGroups, subscribeGroups, toV1GroupsUpdate, + updateGroupMeta, } from './groupsApi'; -import { BadResponseError, scry, subscribe, thread } from './urbit'; +import { scry, subscribe, thread, trackedPoke } from './urbit'; vi.mock('./urbit', async () => { const actual = await vi.importActual('./urbit'); - return { ...actual, scry: vi.fn(), thread: vi.fn(), subscribe: vi.fn() }; + return { + ...actual, + scry: vi.fn(), + thread: vi.fn(), + subscribe: vi.fn(), + trackedPoke: vi.fn(), + }; }); const scryMock = scry as unknown as Mock; const subscribeMock = subscribe as unknown as Mock; +const trackedPokeMock = trackedPoke as unknown as Mock; const threadMock = thread as unknown as Mock; const group: Group = { @@ -79,43 +86,37 @@ test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { }); }); -test('getGroups falls back to the v2 scry when v3 is unavailable', async () => { - scryMock.mockRejectedValueOnce(new BadResponseError(404, 'missing')); - scryMock.mockResolvedValueOnce({}); - - await expect(getGroups()).resolves.toEqual([]); - - expect(scryMock).toHaveBeenNthCalledWith(1, { - app: 'groups', - path: '/v3/groups', - }); - expect(scryMock).toHaveBeenNthCalledWith(2, { - app: 'groups', - path: '/v2/groups', - }); -}); - -// Group updates must ride a single lane so nothing is handled twice; the -// older lanes are only opened when the v3 watch path is unavailable. -test('subscribeGroups subscribes to v3 alone, falling back to v1+v2', async () => { +// Group updates ride a single lane; the legacy lanes stay on the backend for +// older clients but this client must never open them. +test('subscribeGroups subscribes to the v3 lane only', async () => { subscribeMock.mockResolvedValue(1); + await subscribeGroups(() => {}); + const paths = subscribeMock.mock.calls.map(([endpoint]) => endpoint.path); expect(paths).toContain('/v3/groups'); expect(paths).not.toContain('/v1/groups'); expect(paths).not.toContain('/v2/groups'); +}); + +// A trackedPoke resolves only when its watch endpoint receives an event, and +// events arrive solely through that endpoint's own subscription. So the lane +// tracked pokes watch must be the lane we subscribe to, or every group +// mutation hangs until it times out and throws. +test('tracked group pokes watch the lane subscribeGroups subscribes to', async () => { + subscribeMock.mockResolvedValue(1); + trackedPokeMock.mockResolvedValue(undefined); - subscribeMock.mockClear(); - subscribeMock.mockImplementation(({ path }: { path: string }) => - path === '/v3/groups' - ? Promise.reject(new Error('bad-watch-path')) - : Promise.resolve(1) - ); await subscribeGroups(() => {}); - const fallbackPaths = subscribeMock.mock.calls.map( - ([endpoint]) => endpoint.path - ); - expect(fallbackPaths).toContain('/v3/groups'); - expect(fallbackPaths).toContain('/v1/groups'); - expect(fallbackPaths).toContain('/v2/groups'); + const subscribedPaths = subscribeMock.mock.calls + .map(([endpoint]) => endpoint.path) + .filter((path: string) => path.endsWith('/groups')); + + await updateGroupMeta({ + groupId: '~zod/test-group', + meta: { title: 't', description: '', image: '', cover: '' }, + }); + const [, watchEndpoint] = trackedPokeMock.mock.calls[0]; + + expect(subscribedPaths).toContain(watchEndpoint.path); }); diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index ee284b80a2..d3d96b76bc 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -492,34 +492,19 @@ export const createGroup = async ({ } }; -// v3 group scries return v11 format (v9 plus the group blob). Backends -// that haven't shipped the blob yet 404 on them; fall back to the v2 -// path, whose response is identical minus `blob`. -async function scryGroupsWithV2Fallback(v3Path: string, v2Path: string) { - try { - return await scry({ app: 'groups', path: v3Path }); - } catch (err) { - if (err instanceof BadResponseError && err.status === 404) { - logger.log('v3 groups scry unavailable, falling back to v2', v3Path); - return await scry({ app: 'groups', path: v2Path }); - } - throw err; - } -} - +// v3 group scries return v11 format — v9 plus the group blob. export const getGroup = async (groupId: string) => { - const groupData = await scryGroupsWithV2Fallback( - `/v3/ui/groups/${groupId}`, - `/v2/ui/groups/${groupId}` - ); + const path = `/v3/ui/groups/${groupId}`; + + const groupData = await scry({ app: 'groups', path }); return toClientGroupV7(groupId, groupData, true); }; export const getGroups = async () => { - const groupData = await scryGroupsWithV2Fallback( - '/v3/groups', - '/v2/groups' - ); + const groupData = await scry({ + app: 'groups', + path: '/v3/groups', + }); return toClientGroupsV7(groupData, true); }; @@ -539,7 +524,7 @@ export const updateGroupMeta = async ({ }, }, }), - { app: 'groups', path: '/v2/groups' }, + { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { return false; @@ -592,7 +577,7 @@ export const deleteGroup = async (groupId: string) => { }, }, }), - { app: 'groups', path: '/v2/groups' }, + { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { return false; @@ -631,7 +616,7 @@ export const addNavSection = async ({ }, }, }), - { app: 'groups', path: '/v2/groups' }, + { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { return false; @@ -722,7 +707,7 @@ export const addChannelToNavSection = async ({ }, }, }), - { app: 'groups', path: '/v2/groups' }, + { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { return false; @@ -803,7 +788,7 @@ export const addChannelToGroup = async ({ }, }, }), - { app: 'groups', path: '/v2/groups' }, + { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { return false; @@ -1351,40 +1336,16 @@ export const subscribeGroups = async ( // r-group:v11 is a superset of v9 and v10 — every group update, plus the // active-channel deltas that used to require v2, plus the blob — so a - // single lane carries everything and no event is handled twice. Backends - // without the blob bad-watch-path here and fall back to the v1 + v2 pair, - // which between them cover the same ground minus the blob. - try { - await subscribe( - { app: 'groups', path: '/v3/groups' }, - (rawEvent) => { - handleRawGroupsEvent(rawEvent); - } - ); - } catch (err) { - logger.log('v3 groups subscription unavailable, falling back to v1', err); - - void subscribe( - { app: 'groups', path: '/v1/groups' }, - (rawEvent) => { - handleRawGroupsEvent(rawEvent); - } - ); - - // active-channel deltas for third-party channel hosts like %notes exist - // only from v2 on; where even that is missing, init/group sync cover - // membership. - void subscribe( - { app: 'groups', path: '/v2/groups' }, - (rawEvent) => { - if ('r-group' in rawEvent && 'active-channel' in rawEvent['r-group']) { - handleRawGroupsEvent(rawEvent); - } - } - ).catch((fallbackErr) => { - logger.log('v2 groups subscription unavailable', fallbackErr); - }); - } + // single lane carries everything and no event is handled twice. The v1 and + // v2 lanes stay on the backend for older clients, but this client only + // ever reads v3; the desk ships ahead of the app, so there is nothing to + // fall back to. + void subscribe( + { app: 'groups', path: '/v3/groups' }, + (rawEvent) => { + handleRawGroupsEvent(rawEvent); + } + ); // Subscribe to v1/foreigns for foreign group updates void subscribe( diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index 78de18d08a..6ddd54593e 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -10,7 +10,7 @@ import { toClientPinnedItems, } from './groupsApi'; import { toClientHiddenPosts } from './postsApi'; -import { BadResponseError, getCurrentUserId, scry } from './urbit'; +import { getCurrentUserId, scry } from './urbit'; const logger = createDevLogger('initApi', false); @@ -33,17 +33,9 @@ type InitDataOptions = { export const getInitData = async () => { // v9 init is v7 with the group blob included (activity stays v8). - // Backends that haven't shipped the blob yet 404 on it; fall back to - // v7, whose response is identical minus the group blob. const response = await scry({ app: 'groups-ui', path: '/v9/init', - }).catch((err) => { - if (err instanceof BadResponseError && err.status === 404) { - logger.log('v9 init unavailable, falling back to v7'); - return scry({ app: 'groups-ui', path: '/v7/init' }); - } - throw err; }); logger.crumb('got init data from api'); From 22f441a2e91951a20cfb6828a216b63ea764b8a3 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 14:07:15 -0500 Subject: [PATCH 13/25] api: drop the tautological subscribe-lane test It restated a ten-line function rather than asserting anything the code does not already say, and would need editing on every version bump. The tracked-poke test still covers the lane invariant that actually matters, since that one relates two independent places in the file. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/groupsApi.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index cd9bbe688f..ed66556932 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -86,19 +86,6 @@ test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { }); }); -// Group updates ride a single lane; the legacy lanes stay on the backend for -// older clients but this client must never open them. -test('subscribeGroups subscribes to the v3 lane only', async () => { - subscribeMock.mockResolvedValue(1); - - await subscribeGroups(() => {}); - - const paths = subscribeMock.mock.calls.map(([endpoint]) => endpoint.path); - expect(paths).toContain('/v3/groups'); - expect(paths).not.toContain('/v1/groups'); - expect(paths).not.toContain('/v2/groups'); -}); - // A trackedPoke resolves only when its watch endpoint receives an event, and // events arrive solely through that endpoint's own subscription. So the lane // tracked pokes watch must be the lane we subscribe to, or every group From 7c59ccb23d5ea162718b0184494c819df7260479 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 14:27:44 -0500 Subject: [PATCH 14/25] api: retire stale version names on the group wire types Audited every version-tagged type the blob change touched, directly or transitively: V1GroupResponse -> GroupResponse named for the /v1 path we no longer read, on a type that is now v11, and the only versioned name among its ten GroupResponse* siblings GroupV7 -> GroupV11 gained blob GroupsV7 -> GroupsV11 map of the above toV1GroupsUpdate -> toGroupsUpdate toClientGroupV7 -> toClientGroup one wire version reaches these toClientGroupsV7 -> toClientGroups now, so drop the suffix extractChannelReadersFromV7Groups -> extractChannelReadersFromGroups Deletes ChangesV8, ChangesV7, GroupsInit7 and GroupsInit6, which were aliases kept only for the fallbacks; the surviving fixture casts point at GroupsInit9. Left alone: GroupChannelV7, SectionV7, SectionsV7 and GroupPreviewV7 describe shapes that are unchanged from v7 through v11, so their names are accurate and renaming them would make them wrong. Co-Authored-By: Claude Fable 5 --- packages/api/src/__tests__/initApi.test.ts | 4 +- packages/api/src/client/changesApi.ts | 6 +-- packages/api/src/client/groupsApi.test.ts | 8 ++-- packages/api/src/client/groupsApi.ts | 46 +++++++++---------- packages/api/src/client/initApi.ts | 12 ++--- packages/api/src/urbit/groups.ts | 12 ++--- packages/api/src/urbit/ui.ts | 16 ++----- packages/shared/src/db/queries.test.ts | 6 +-- packages/shared/src/store/sync/sync.test.ts | 12 ++--- .../scripts/commands/groups-verification.ts | 2 +- 10 files changed, 59 insertions(+), 65 deletions(-) diff --git a/packages/api/src/__tests__/initApi.test.ts b/packages/api/src/__tests__/initApi.test.ts index bb8da8e96f..56b99c1813 100644 --- a/packages/api/src/__tests__/initApi.test.ts +++ b/packages/api/src/__tests__/initApi.test.ts @@ -4,9 +4,9 @@ import { toInitData } from '../client/initApi'; import type * as ub from '../urbit'; import rawGroupsInit6 from './fixtures/groupsInit5.json'; -const groupsInit6 = rawGroupsInit6 as unknown as ub.GroupsInit6; +const groupsInit6 = rawGroupsInit6 as unknown as ub.GroupsInit9; const currentUserId = '~solfer-magfed'; -const toTestInitData = (response: ub.GroupsInit7) => +const toTestInitData = (response: ub.GroupsInit9) => toInitData(response, { currentUserId }); describe('toInitData', () => { diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 2ee8a1a41c..94ceb91ac8 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -4,7 +4,7 @@ import type * as db from '../types/models'; import * as ub from '../urbit'; import { toClientUnreads } from './activityApi'; import { contactToClientProfile } from './contactsApi'; -import { toClientGroupsV7 } from './groupsApi'; +import { toClientGroups } from './groupsApi'; import { toPostsData } from './postsApi'; import { checkIsNodeBusyWithHints, scry } from './urbit'; @@ -16,7 +16,7 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); - // v10 changes is v8 plus the group blob, which rides inside GroupV7. + // v10 changes is v8 plus the group blob, which rides inside GroupV11. const response = await scry({ app: 'groups-ui', path: `/v10/changes/${encodedTimestamp}`, @@ -30,7 +30,7 @@ export async function fetchChangesSince(timestamp: number): Promise< } export function parseChanges(input: ub.ChangesV10): db.ChangesResult { - const groups = toClientGroupsV7(input.groups, true); + const groups = toClientGroups(input.groups, true); const channelPosts = Object.entries(input.channels).flatMap( ([channelId, posts]) => (posts ? toPostsData(channelId, posts).posts : []) diff --git a/packages/api/src/client/groupsApi.test.ts b/packages/api/src/client/groupsApi.test.ts index ed66556932..7f8ecf30c5 100644 --- a/packages/api/src/client/groupsApi.test.ts +++ b/packages/api/src/client/groupsApi.test.ts @@ -5,7 +5,7 @@ import type { Group } from '../types/models'; import { createGroup, subscribeGroups, - toV1GroupsUpdate, + toGroupsUpdate, updateGroupMeta, } from './groupsApi'; import { scry, subscribe, thread, trackedPoke } from './urbit'; @@ -62,9 +62,9 @@ test('createGroup recovers only when a response body stalls after headers', asyn }); }); -test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { +test('toGroupsUpdate maps blob responses to editGroupBlob', () => { expect( - toV1GroupsUpdate({ + toGroupsUpdate({ flag: '~zod/test-group', 'r-group': { blob: '{"custom":"payload"}' }, }) @@ -75,7 +75,7 @@ test('toV1GroupsUpdate maps blob responses to editGroupBlob', () => { }); expect( - toV1GroupsUpdate({ + toGroupsUpdate({ flag: '~zod/test-group', 'r-group': { blob: null }, }) diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index d3d96b76bc..a84ae1d967 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -443,7 +443,7 @@ export const createGroup = async ({ }; try { - const result = await thread({ + const result = await thread({ desk: 'groups', inputMark: 'group-create-thread', threadName: 'group-create-1', @@ -454,7 +454,7 @@ export const createGroup = async ({ context: 'group-create-thread request succeeded', }); - return toClientGroupV7(group.id, result, true); + return toClientGroup(group.id, result, true); } catch (err) { // Only a stalled body after the response headers arrived is safe to // recover from: the create thread has finished, but its response was lost @@ -496,16 +496,16 @@ export const createGroup = async ({ export const getGroup = async (groupId: string) => { const path = `/v3/ui/groups/${groupId}`; - const groupData = await scry({ app: 'groups', path }); - return toClientGroupV7(groupId, groupData, true); + const groupData = await scry({ app: 'groups', path }); + return toClientGroup(groupId, groupData, true); }; export const getGroups = async () => { - const groupData = await scry({ + const groupData = await scry({ app: 'groups', path: '/v3/groups', }); - return toClientGroupsV7(groupData, true); + return toClientGroups(groupData, true); }; export const updateGroupMeta = async ({ @@ -515,7 +515,7 @@ export const updateGroupMeta = async ({ groupId: string; meta: ub.GroupMeta; }) => { - return await trackedPoke( + return await trackedPoke( groupAction4({ group: { flag: groupId, @@ -544,7 +544,7 @@ export const updateGroupBlob = async ({ groupId: string; blob: string | null; }) => { - return await trackedPoke( + return await trackedPoke( groupAction5({ group: { flag: groupId, @@ -568,7 +568,7 @@ export const updateGroupBlob = async ({ }; export const deleteGroup = async (groupId: string) => { - return await trackedPoke( + return await trackedPoke( groupAction4({ group: { flag: groupId, @@ -596,7 +596,7 @@ export const addNavSection = async ({ groupId: string; navSection: db.GroupNavSection; }) => { - return await trackedPoke( + return await trackedPoke( groupAction4({ group: { flag: groupId, @@ -693,7 +693,7 @@ export const addChannelToNavSection = async ({ channelId: string; }) => { logger.log('addChannelToNavSection', { groupId, navSectionId, channelId }); - return await trackedPoke( + return await trackedPoke( groupAction4({ group: { flag: groupId, @@ -774,7 +774,7 @@ export const addChannelToGroup = async ({ groupId: string; sectionId: string; }) => { - return await trackedPoke( + return await trackedPoke( groupAction4({ group: { flag: groupId, @@ -1325,10 +1325,10 @@ export const subscribeGroups = async ( eventHandler: (update: GroupUpdate) => void ) => { const handleRawGroupsEvent = ( - rawEvent: ub.V1GroupResponse, + rawEvent: ub.GroupResponse, shouldHandleUpdate = (_update: GroupUpdate) => true ) => { - const update = toV1GroupsUpdate(rawEvent); + const update = toGroupsUpdate(rawEvent); if (update && shouldHandleUpdate(update)) { eventHandler(update); } @@ -1340,7 +1340,7 @@ export const subscribeGroups = async ( // v2 lanes stay on the backend for older clients, but this client only // ever reads v3; the desk ships ahead of the app, so there is nothing to // fall back to. - void subscribe( + void subscribe( { app: 'groups', path: '/v3/groups' }, (rawEvent) => { handleRawGroupsEvent(rawEvent); @@ -1357,8 +1357,8 @@ export const subscribeGroups = async ( ); }; -export const toV1GroupsUpdate = ( - rawEvent: ub.V1GroupResponse +export const toGroupsUpdate = ( + rawEvent: ub.GroupResponse ): GroupUpdate | null => { const groupId = rawEvent.flag; const event = rawEvent['r-group']; @@ -1367,7 +1367,7 @@ export const toV1GroupsUpdate = ( if ('create' in event) { return { type: 'addGroup', - group: toClientGroupV7(groupId, event.create, true), + group: toClientGroup(groupId, event.create, true), }; } @@ -1743,8 +1743,8 @@ const extractFlaggedPosts = ( return flaggedPosts; }; -export function toClientGroupsV7( - groups: Record, +export function toClientGroups( + groups: Record, isJoined: boolean, currentUserId = getCurrentUserId() ) { @@ -1752,13 +1752,13 @@ export function toClientGroupsV7( return []; } return Object.entries(groups).map(([id, group]) => { - return toClientGroupV7(id, group, isJoined, currentUserId); + return toClientGroup(id, group, isJoined, currentUserId); }); } -export function toClientGroupV7( +export function toClientGroup( id: string, - group: ub.GroupV7, + group: ub.GroupV11, isJoined: boolean, currentUserId = getCurrentUserId() ): db.Group { diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index 6ddd54593e..508e52de1a 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -5,8 +5,8 @@ import { toClientUnreads } from './activityApi'; import { ChannelInit, toClientChannelsInit } from './channelsApi'; import { toClientDms, toClientGroupDms } from './chatApi'; import { + toClientGroups, toClientGroupsFromForeigns, - toClientGroupsV7, toClientPinnedItems, } from './groupsApi'; import { toClientHiddenPosts } from './postsApi'; @@ -43,8 +43,8 @@ export const getInitData = async () => { return toInitData(response, { currentUserId: getCurrentUserId() }); }; -function extractChannelReadersFromV7Groups( - groups: Record +function extractChannelReadersFromGroups( + groups: Record ): Record { const readers: Record = {}; Object.entries(groups).forEach(([_groupId, group]) => { @@ -58,7 +58,7 @@ function extractChannelReadersFromV7Groups( } function extractJoinedGroupChannelsFromV7Groups( - groups: Record + groups: Record ): string[] { const joinedChannelIds = new Set(); @@ -80,7 +80,7 @@ export const toInitData = ( const pins = toClientPinnedItems(response.pins); - const channelReaders = extractChannelReadersFromV7Groups(response.groups); + const channelReaders = extractChannelReadersFromGroups(response.groups); const channelsInit = toClientChannelsInit( response.channel.channels, @@ -102,7 +102,7 @@ export const toInitData = ( logger.crumb('converting groups to client data'); - const groups = toClientGroupsV7(response.groups, true, options.currentUserId); + const groups = toClientGroups(response.groups, true, options.currentUserId); logger.crumb('converting unjoined groups to client data'); diff --git a/packages/api/src/urbit/groups.ts b/packages/api/src/urbit/groups.ts index 26d45fac0e..be4d79f25b 100644 --- a/packages/api/src/urbit/groups.ts +++ b/packages/api/src/urbit/groups.ts @@ -339,14 +339,14 @@ export interface GroupUpdate { diff: GroupDiff; } -// v9 Group Response (r-groups) -export interface V1GroupResponse { +// r-groups as delivered on /v3/groups +export interface GroupResponse { flag: string; ['r-group']: GroupResponseData; } export type GroupResponseData = - | { create: GroupV7 } + | { create: GroupV11 } | { meta: GroupMeta } | { blob: string | null } | { entry: GroupResponseEntry } @@ -469,8 +469,8 @@ export interface Groups { [flag: string]: Group; } -export interface GroupsV7 { - [flag: string]: GroupV7; +export interface GroupsV11 { + [flag: string]: GroupV11; } export interface GroupPreview { @@ -518,7 +518,7 @@ export interface AdmissionRequest { requestedAt?: number; } -export interface GroupV7 { +export interface GroupV11 { meta: GroupMeta; blob?: string | null; admissions: Admissions; diff --git a/packages/api/src/urbit/ui.ts b/packages/api/src/urbit/ui.ts index 2b32a83471..af67c4efd9 100644 --- a/packages/api/src/urbit/ui.ts +++ b/packages/api/src/urbit/ui.ts @@ -2,11 +2,11 @@ import { Activity } from './activity'; import { ChannelHeadsResponse, Channels, Posts } from './channel'; import { ContactBookEntry } from './contact'; import { ChatHeadsResponse, DMInit2, Writs } from './dms'; -import { Foreigns, GroupV7, Groups, GroupsV7 } from './groups'; +import { Foreigns, GroupV11, Groups, GroupsV11 } from './groups'; -// v9 init is v7 plus the group blob, which rides along inside GroupV7. +// /v9/init: the v7 payload plus the group blob, which rides inside GroupV11. export interface GroupsInit9 { - groups: Record; + groups: Record; foreigns: Foreigns; channel: { channels: Channels; @@ -17,9 +17,6 @@ export interface GroupsInit9 { chat: DMInit2; } -export type GroupsInit7 = GroupsInit9; -export type GroupsInit6 = GroupsInit9; - export interface CombinedHeads { dms: ChatHeadsResponse; channels: ChannelHeadsResponse; @@ -33,18 +30,15 @@ export interface Changes { activity: Activity; } -// v10 changes is v8 plus the group blob, which rides along inside GroupV7. +// /v10/changes: the v8 payload plus the group blob, inside GroupV11. export interface ChangesV10 { - groups: GroupsV7; + groups: GroupsV11; channels: Record; chat: Record; contacts: Record; activity: Activity; } -export type ChangesV8 = ChangesV10; -export type ChangesV7 = ChangesV10; - export interface PostsInit { channels: Record; chat: Record; diff --git a/packages/shared/src/db/queries.test.ts b/packages/shared/src/db/queries.test.ts index 4efdb66389..0100ef79cc 100644 --- a/packages/shared/src/db/queries.test.ts +++ b/packages/shared/src/db/queries.test.ts @@ -1,6 +1,6 @@ import { QueryObserver } from '@tanstack/react-query'; import { v0PeersToClientProfiles } from '@tloncorp/api'; -import { toClientGroupsV7 } from '@tloncorp/api'; +import { toClientGroups } from '@tloncorp/api'; import type * as ub from '@tloncorp/api/urbit/groups'; import * as $ from 'drizzle-orm'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -23,8 +23,8 @@ import * as queries from './queries'; import { queryClient } from './reactQuery'; import { ChannelUnread, GroupUnread, Post, ThreadUnreadState } from './types'; -const groupsData = toClientGroupsV7( - groupsResponse as unknown as Record, +const groupsData = toClientGroups( + groupsResponse as unknown as Record, true ); diff --git a/packages/shared/src/store/sync/sync.test.ts b/packages/shared/src/store/sync/sync.test.ts index 28c2811db6..deee6508ef 100644 --- a/packages/shared/src/store/sync/sync.test.ts +++ b/packages/shared/src/store/sync/sync.test.ts @@ -1,6 +1,6 @@ import { StructuredChannelDescriptionPayload, - toClientGroupV7, + toClientGroup, } from '@tloncorp/api'; import '@tloncorp/api'; import { @@ -10,7 +10,7 @@ import { } from '@tloncorp/api'; import { CombinedHeads, - GroupsInit6, + GroupsInit9, PagedPosts, PostDataResponse, } from '@tloncorp/api/urbit'; @@ -18,7 +18,7 @@ import { ContactBookScryResult1, Contact as UrbitContact, } from '@tloncorp/api/urbit/contact'; -import { GroupV7 as UrbitGroup } from '@tloncorp/api/urbit/groups'; +import { GroupV11 as UrbitGroup } from '@tloncorp/api/urbit/groups'; import * as $ from 'drizzle-orm'; import { pick } from 'lodash'; import { expect, test, vi } from 'vitest'; @@ -61,8 +61,8 @@ const contactsData = rawContactsData as unknown as Record; const contactBookData = rawContactsData2 as unknown as ContactBookScryResult1; const suggestionsData = rawContactSuggestionsData as unknown as string[]; const groupsData = rawGroupsData as unknown as Record; -const groupsInitData = rawGroupsInitData as unknown as GroupsInit6; -const groupsInitData2 = rawGroupsInit2 as unknown as GroupsInit6; +const groupsInitData = rawGroupsInitData as unknown as GroupsInit9; +const groupsInitData2 = rawGroupsInit2 as unknown as GroupsInit9; const headsData = rawHeadsData as unknown as CombinedHeads; setupDatabaseTestSuite(); @@ -496,7 +496,7 @@ const groupId = '~solfer-magfed/test-group'; const channelId = 'chat/~solfer-magfed/test-channel'; const testGroupData: db.Group = { - ...toClientGroupV7( + ...toClientGroup( groupId, Object.values(rawGroupsData)[0] as unknown as UrbitGroup, true diff --git a/packages/tlon-skill/scripts/commands/groups-verification.ts b/packages/tlon-skill/scripts/commands/groups-verification.ts index d3482ec133..c510fa5e04 100644 --- a/packages/tlon-skill/scripts/commands/groups-verification.ts +++ b/packages/tlon-skill/scripts/commands/groups-verification.ts @@ -9,7 +9,7 @@ /** * Narrow view of the `/v2/ui/groups/{id}` scry response — only the fields the - * admin-verification flows read. A subset of `@tloncorp/api`'s `GroupV7`. + * admin-verification flows read. A subset of `@tloncorp/api`'s `GroupV11`. */ export type RawGroupForAdminVerification = { admins?: string[]; From e53d1aba8114a8b0cf0532efc3e80459f9208ecc Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 15:18:28 -0500 Subject: [PATCH 15/25] api/shared: drop comments that restate the code Version arithmetic like "v10 changes is v8 plus the group blob" is already carried by the type names and the paths beside them. Two of these were also stale, still citing the v1/v2 fallbacks that were removed. What stays is the non-obvious: why group-action-4 would nack a blob, why undefined and null differ in the group upsert, why tracked pokes must watch the subscribed lane, and the Hoon type names each wire type maps to, which TypeScript cannot show. Co-Authored-By: Claude Fable 5 --- packages/api/src/client/changesApi.ts | 1 - packages/api/src/client/groupsApi.ts | 19 +++++-------------- packages/api/src/client/initApi.ts | 1 - packages/api/src/urbit/groups.ts | 11 +++++------ packages/api/src/urbit/ui.ts | 2 -- packages/shared/src/db/queries.ts | 5 ++--- .../src/store/sync/handleGroupUpdate.test.ts | 9 ++------- 7 files changed, 14 insertions(+), 34 deletions(-) diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 94ceb91ac8..2c7ebf2e87 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -16,7 +16,6 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); - // v10 changes is v8 plus the group blob, which rides inside GroupV11. const response = await scry({ app: 'groups-ui', path: `/v10/changes/${encodedTimestamp}`, diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index a84ae1d967..a78ecc258c 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -35,9 +35,6 @@ function groupAction4(action: ub.GroupActionV4) { }; } -// group-action-5 carries the same shape as group-action-4 plus the -// %blob variant; only blob pokes use it so older backends keep -// accepting every other action. function groupAction5(action: ub.GroupActionV5) { return { app: 'groups', @@ -492,7 +489,6 @@ export const createGroup = async ({ } }; -// v3 group scries return v11 format — v9 plus the group blob. export const getGroup = async (groupId: string) => { const path = `/v3/ui/groups/${groupId}`; @@ -553,7 +549,6 @@ export const updateGroupBlob = async ({ }, }, }), - // blob responses ride /v3/groups only; they are stripped from v1/v2 { app: 'groups', path: '/v3/groups' }, (event) => { if (!('r-group' in event)) { @@ -1334,12 +1329,9 @@ export const subscribeGroups = async ( } }; - // r-group:v11 is a superset of v9 and v10 — every group update, plus the - // active-channel deltas that used to require v2, plus the blob — so a - // single lane carries everything and no event is handled twice. The v1 and - // v2 lanes stay on the backend for older clients, but this client only - // ever reads v3; the desk ships ahead of the app, so there is nothing to - // fall back to. + // r-group:v11 is a superset of v9 and v10, so one lane carries every update + // and nothing is handled twice. The desk ships ahead of the app, so there + // is no older backend to fall back to. void subscribe( { app: 'groups', path: '/v3/groups' }, (rawEvent) => { @@ -1857,9 +1849,8 @@ export function toClientGroup( roles, privacy: group.admissions.privacy, ...toClientGroupMeta(group.meta), - // Pass undefined through rather than coercing to null: a surface that - // predates the blob (the v1/v2 fallbacks) says nothing about it, so - // upserts leave any known blob alone. An explicit null is a real clear. + // undefined and null differ downstream: omitting blob leaves a stored + // value alone, an explicit null clears it. blob: group.blob, haveInvite: isJoined ? false : undefined, haveRequestedInvite: isJoined ? false : undefined, diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index 508e52de1a..ade67ee6d4 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -32,7 +32,6 @@ type InitDataOptions = { }; export const getInitData = async () => { - // v9 init is v7 with the group blob included (activity stays v8). const response = await scry({ app: 'groups-ui', path: '/v9/init', diff --git a/packages/api/src/urbit/groups.ts b/packages/api/src/urbit/groups.ts index be4d79f25b..eca659e2ed 100644 --- a/packages/api/src/urbit/groups.ts +++ b/packages/api/src/urbit/groups.ts @@ -339,7 +339,7 @@ export interface GroupUpdate { diff: GroupDiff; } -// r-groups as delivered on /v3/groups +// r-groups export interface GroupResponse { flag: string; ['r-group']: GroupResponseData; @@ -627,8 +627,7 @@ export interface GroupInviteAction { note: Story | null; } -// Group actions (a-groups). The outer envelope is the same at every version; -// only the inner a-group payload gains variants, so parameterize on it. +// Group actions (a-groups) export type GroupActionEnvelope = | { group: { @@ -643,11 +642,11 @@ export type GroupActionEnvelope = leave: string; // flag }; -// a-groups:v8, poked at mark group-action-4 +// a-groups:v8 export type GroupActionV4 = GroupActionEnvelope; -// a-groups:v11, poked at mark group-action-5 — v4 plus the group blob. Kept -// separate because group-action-4's dejs has no blob key and would nack it. +// a-groups:v11. Kept separate from v4 because group-action-4's dejs has no +// blob key and would nack one. export type GroupActionV5 = GroupActionEnvelope; export type GroupActionV5Data = GroupAction | { blob: string | null }; diff --git a/packages/api/src/urbit/ui.ts b/packages/api/src/urbit/ui.ts index af67c4efd9..cff40adcf8 100644 --- a/packages/api/src/urbit/ui.ts +++ b/packages/api/src/urbit/ui.ts @@ -4,7 +4,6 @@ import { ContactBookEntry } from './contact'; import { ChatHeadsResponse, DMInit2, Writs } from './dms'; import { Foreigns, GroupV11, Groups, GroupsV11 } from './groups'; -// /v9/init: the v7 payload plus the group blob, which rides inside GroupV11. export interface GroupsInit9 { groups: Record; foreigns: Foreigns; @@ -30,7 +29,6 @@ export interface Changes { activity: Activity; } -// /v10/changes: the v8 payload plus the group blob, inside GroupV11. export interface ChangesV10 { groups: GroupsV11; channels: Record; diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 35755ebd60..825db1cfbc 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -1408,9 +1408,8 @@ export const insertGroups = createWriteQuery( $groups.coverImage, $groups.title, $groups.description, - // blob-less surfaces (v1 create, v2 fallback scries) omit - // the key entirely; only overwrite when the source carried - // it, so an explicit null still clears. + // only overwrite when the source carried a blob; omitting + // the key must not clear a stored one ...(group.blob !== undefined ? [$groups.blob] : []), $groups.privacy, $groups.joinStatus, diff --git a/packages/shared/src/store/sync/handleGroupUpdate.test.ts b/packages/shared/src/store/sync/handleGroupUpdate.test.ts index 346a2d75d5..01693dd2e0 100644 --- a/packages/shared/src/store/sync/handleGroupUpdate.test.ts +++ b/packages/shared/src/store/sync/handleGroupUpdate.test.ts @@ -288,8 +288,6 @@ test('addChannel propagates group sync failures for normal channels', async () = } }); -// `editGroupBlob` carries the group's opaque custom payload; the handler -// writes it to the group row verbatim and null clears it. test('editGroupBlob writes and clears the group blob column', async () => { const groupId = '~bus/blob-group'; @@ -326,10 +324,7 @@ test('editGroupBlob writes and clears the group blob column', async () => { expect(group?.blob).toBeNull(); }); -// A payload that carries no blob key says nothing about the blob, so an -// upsert from it must leave a known blob alone; an explicit null is a real -// clear and must still land. Group updates all arrive on one lane now, so -// this guards future blob-less surfaces rather than a live race. +// Omitting the blob key must not clear a stored blob; an explicit null must. test('addGroup preserves the blob on blob-less upserts and clears on null', async () => { const groupId = '~bus/blob-upsert-group'; const base = { @@ -350,7 +345,7 @@ test('addGroup preserves the blob on blob-less upserts and clears on null', asyn ); }); - // blob-less upsert (v1 create shape) leaves the blob alone + // blob-less upsert leaves it alone await batchEffects('test:addGroup-no-blob', async (ctx) => { await handleGroupUpdate({ type: 'addGroup', group: { ...base } }, ctx); }); From c8a873316bca8f7249574652e79f577206b39aca Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 3 Aug 2026 17:09:37 -0500 Subject: [PATCH 16/25] groups: retire group-action-4 client-side, poke only action-5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client already hard-requires the new desk — getGroups, getGroup and subscribeGroups all read /v3 with no fallback — so keeping action-4 bought no compatibility while leaving a live footgun about which mark can carry which action. Moves all 29 pokes to group-action-5: the 25 in groupsApi via a single merged groupAction() helper, plus groupNavigationBatchUpdate, the flag-content poke in postsApi, four raw pokes in tlon-skill, and the web mock. The Hoon %group-action-4 arm stays for older app builds. With one live mark the GroupActionEnvelope parameterization has no purpose, so it collapses back to a concrete GroupActionV5 over a single GroupAction that includes blob; GroupActionV4 and GroupActionV5Data are gone. Adds dejs tests in tests/lib/groups-json proving the migrated actions still parse at v11 — meta, delete, entry/privacy and leave — alongside blob set and clear. v11's a-group dejs is v8's arm list plus blob+(mu so), with identical sub-parsers. Co-Authored-By: Claude Fable 5 --- apps/tlon-web/src/mocks/handlers.ts | 6 +-- desk/tests/lib/groups-json.hoon | 45 +++++++++++++++++- packages/api/src/client/groupsApi.ts | 62 +++++++++++-------------- packages/api/src/client/postsApi.ts | 2 +- packages/api/src/urbit/groups.ts | 18 ++----- packages/tlon-skill/scripts/channels.ts | 4 +- packages/tlon-skill/scripts/groups.ts | 4 +- 7 files changed, 84 insertions(+), 57 deletions(-) diff --git a/apps/tlon-web/src/mocks/handlers.ts b/apps/tlon-web/src/mocks/handlers.ts index 21047a2921..57b08086a5 100644 --- a/apps/tlon-web/src/mocks/handlers.ts +++ b/apps/tlon-web/src/mocks/handlers.ts @@ -7,7 +7,7 @@ import { DmRsvp, WritDiff, } from '@tloncorp/api/urbit/dms'; -import { GroupActionV4 } from '@tloncorp/api/urbit/groups'; +import { GroupActionV5 } from '@tloncorp/api/urbit/groups'; import UrbitMock, { Handler, Message, @@ -149,9 +149,9 @@ const groups: Handler[] = [ { action: 'poke', app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', returnSubscription: specificGroupSub, - dataResponder: (req: Message & Poke) => { + dataResponder: (req: Message & Poke) => { if ('group' in req.json) { return createResponse(req, 'diff', { flag: req.json.group.flag, diff --git a/desk/tests/lib/groups-json.hoon b/desk/tests/lib/groups-json.hoon index 71744ec84c..33dc4c4193 100644 --- a/desk/tests/lib/groups-json.hoon +++ b/desk/tests/lib/groups-json.hoon @@ -6,7 +6,7 @@ :: frontend can send through the same `ui-action:dejs:gj` parser the real :: JSON poke hits: group, pinned group channel, DM, and club/group DM. :: -/- u=ui +/- u=ui, gv=groups-ver /+ *test, gj=groups-json |% :: +parse-set-order: run a real JSON string through the client-facing parser @@ -45,4 +45,47 @@ %+ expect-eq !> `action:u`[%pins %set-order ~] !> (parse-set-order '{"pins":{"set-order":[]}}') +:: +parse-a-groups: run a real JSON string through the parser the +:: %group-action-5 poke hits +:: +++ parse-a-groups + |= body=@t + ^- a-groups:v11:gv + (a-groups:v11:dejs:gj (need (de:json:html body))) +:: +test-a-groups-dejs-blob: the group blob parses, and null clears it +:: +++ test-a-groups-dejs-blob + ;: weld + %+ expect-eq + !> `a-groups:v11:gv`[%group [~zod %group] %blob `'opaque-payload'] + !> %- parse-a-groups + '{"group":{"flag":"~zod/group","a-group":{"blob":"opaque-payload"}}}' + :: + %+ expect-eq + !> `a-groups:v11:gv`[%group [~zod %group] %blob ~] + !> (parse-a-groups '{"group":{"flag":"~zod/group","a-group":{"blob":null}}}') + == +:: +test-a-groups-dejs-non-blob: every action the client used to send at +:: %group-action-4 still parses now that they all ride %group-action-5 +:: +++ test-a-groups-dejs-non-blob + ;: weld + %+ expect-eq + !> `a-groups:v11:gv`[%group [~zod %group] %meta 'T' 'D' 'I' 'C'] + !> %- parse-a-groups + '{"group":{"flag":"~zod/group","a-group":{"meta":{"title":"T","description":"D","image":"I","cover":"C"}}}}' + :: + %+ expect-eq + !> `a-groups:v11:gv`[%group [~zod %group] %delete ~] + !> (parse-a-groups '{"group":{"flag":"~zod/group","a-group":{"delete":null}}}') + :: + %+ expect-eq + !> `a-groups:v11:gv`[%group [~zod %group] %entry %privacy %public] + !> %- parse-a-groups + '{"group":{"flag":"~zod/group","a-group":{"entry":{"privacy":"public"}}}}' + :: + %+ expect-eq + !> `a-groups:v11:gv`[%leave [~zod %group]] + !> (parse-a-groups '{"leave":"~zod/group"}') + == -- diff --git a/packages/api/src/client/groupsApi.ts b/packages/api/src/client/groupsApi.ts index a78ecc258c..e0f956c6ca 100644 --- a/packages/api/src/client/groupsApi.ts +++ b/packages/api/src/client/groupsApi.ts @@ -27,15 +27,7 @@ import { const logger = createDevLogger('groupsApi', false); -function groupAction4(action: ub.GroupActionV4) { - return { - app: 'groups', - mark: 'group-action-4', - json: action, - }; -} - -function groupAction5(action: ub.GroupActionV5) { +function groupAction(action: ub.GroupActionV5) { return { app: 'groups', mark: 'group-action-5', @@ -49,7 +41,7 @@ function groupNavigationBatchUpdate( ): Poke { return { app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag, @@ -91,7 +83,7 @@ export function acceptGroupJoin({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -115,7 +107,7 @@ export function rejectGroupJoin({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -147,7 +139,7 @@ export function inviteGroupMembers({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ invite: { flag: groupId, ships: contactIds, @@ -168,7 +160,7 @@ export function revokeGroupMemberInvites({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -203,7 +195,7 @@ export async function kickUsersFromGroup({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -227,7 +219,7 @@ export async function banUsersFromGroup({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -250,7 +242,7 @@ export async function unbanUsersFromGroup({ contactIds: string[]; }) { return poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -289,7 +281,7 @@ export async function updateGroupPrivacy(params: { }) { // In v8/v9, privacy is a single unified setting that includes secret/private/public return poke( - groupAction4({ + groupAction({ group: { flag: params.groupId, 'a-group': { @@ -512,7 +504,7 @@ export const updateGroupMeta = async ({ meta: ub.GroupMeta; }) => { return await trackedPoke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -541,7 +533,7 @@ export const updateGroupBlob = async ({ blob: string | null; }) => { return await trackedPoke( - groupAction5({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -564,7 +556,7 @@ export const updateGroupBlob = async ({ export const deleteGroup = async (groupId: string) => { return await trackedPoke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -592,7 +584,7 @@ export const addNavSection = async ({ navSection: db.GroupNavSection; }) => { return await trackedPoke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -632,7 +624,7 @@ export const deleteNavSection = async ({ groupId: string; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -656,7 +648,7 @@ export const updateNavSection = async ({ navSection: db.GroupNavSection; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -689,7 +681,7 @@ export const addChannelToNavSection = async ({ }) => { logger.log('addChannelToNavSection', { groupId, navSectionId, channelId }); return await trackedPoke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -738,7 +730,7 @@ export const addChannelListingToGroup = async ({ join?: boolean; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -770,7 +762,7 @@ export const addChannelToGroup = async ({ sectionId: string; }) => { return await trackedPoke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -810,7 +802,7 @@ export const updateChannel = async ({ channel: GroupChannelV7; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -834,7 +826,7 @@ export const deleteChannel = async ({ channelId: string; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -894,7 +886,7 @@ export const addGroupRole = async ({ meta: db.ClientMeta; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -923,7 +915,7 @@ export const deleteGroupRole = async ({ roleId: string; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -949,7 +941,7 @@ export const updateGroupRole = async ({ meta: db.ClientMeta; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -980,7 +972,7 @@ export const addMembersToRole = async ({ ships: string[]; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -1006,7 +998,7 @@ export const removeMembersFromRole = async ({ ships: string[]; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { @@ -1032,7 +1024,7 @@ export const removeAllRolesFromMembers = async ({ roleIds: string[]; }) => { return await poke( - groupAction4({ + groupAction({ group: { flag: groupId, 'a-group': { diff --git a/packages/api/src/client/postsApi.ts b/packages/api/src/client/postsApi.ts index a7b9f9ec98..dcc17e7257 100644 --- a/packages/api/src/client/postsApi.ts +++ b/packages/api/src/client/postsApi.ts @@ -987,7 +987,7 @@ export async function reportPost( const action = { app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag: groupId, diff --git a/packages/api/src/urbit/groups.ts b/packages/api/src/urbit/groups.ts index eca659e2ed..969a0b8554 100644 --- a/packages/api/src/urbit/groups.ts +++ b/packages/api/src/urbit/groups.ts @@ -627,12 +627,12 @@ export interface GroupInviteAction { note: Story | null; } -// Group actions (a-groups) -export type GroupActionEnvelope = +// a-groups:v11 +export type GroupActionV5 = | { group: { flag: string; - 'a-group': TGroupAction; + 'a-group': GroupAction; }; } | { @@ -642,17 +642,9 @@ export type GroupActionEnvelope = leave: string; // flag }; -// a-groups:v8 -export type GroupActionV4 = GroupActionEnvelope; - -// a-groups:v11. Kept separate from v4 because group-action-4's dejs has no -// blob key and would nack one. -export type GroupActionV5 = GroupActionEnvelope; - -export type GroupActionV5Data = GroupAction | { blob: string | null }; - export type GroupAction = | { meta: GroupMeta } + | { blob: string | null } | { entry: GroupEntryAction } | { seat: { ships: string[]; 'a-seat': GroupSeatAction } } | { role: { roles: string[]; 'a-role': GroupRoleAction } } @@ -757,7 +749,7 @@ export type ForeignGroupAction = | { cancel: null } | { decline: { token: string | null } }; -// Types for batch navigation updates (group-action-4) +// Types for batch navigation updates (group-action-5) export interface GroupNavigationSectionData { meta: GroupMeta; order: string[]; diff --git a/packages/tlon-skill/scripts/channels.ts b/packages/tlon-skill/scripts/channels.ts index 5aa1630820..ed60975f0e 100644 --- a/packages/tlon-skill/scripts/channels.ts +++ b/packages/tlon-skill/scripts/channels.ts @@ -477,7 +477,7 @@ async function addReaders(groupFlag: string, nest: string, roles: string[]) { ); await poke({ app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag: groupFlag, @@ -502,7 +502,7 @@ async function removeReaders(groupFlag: string, nest: string, roles: string[]) { ); await poke({ app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag: groupFlag, diff --git a/packages/tlon-skill/scripts/groups.ts b/packages/tlon-skill/scripts/groups.ts index 8cb91d2f11..e57cec52f5 100644 --- a/packages/tlon-skill/scripts/groups.ts +++ b/packages/tlon-skill/scripts/groups.ts @@ -533,7 +533,7 @@ async function verifyGroupCreated(groupId: string): Promise { async function setAdminRole(groupId: string, roleId: string) { await poke({ app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag: groupId, @@ -1418,7 +1418,7 @@ async function demoteMemberFromAdmin(groupId: string, ships: string[]) { // our own permission part-way through and get the remaining removals rejected. await poke({ app: 'groups', - mark: 'group-action-4', + mark: 'group-action-5', json: { group: { flag: groupId, From f3d6e73fcecd4a235173597476d881dc72a83598 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 4 Aug 2026 11:02:33 -0500 Subject: [PATCH 17/25] =?UTF-8?q?groups:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?deflate=20action-4,=20drop=20mark-warmer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete lib/mark-warmer.hoon and its two imports; on kelvin 408 all marks build by default, so pre-building them is redundant - deflate %group-action-4 into %group-action-5 with the $(+< ...) pattern %group-action-3 already uses, rather than duplicating the dispatch; a-groups:v8 nests under v11 - add %group-action-4 to the strict mark list, which it was missing - drop two casts the typed rails make unnecessary - give both downgrade lanes in +go-response the same ?~ shape; v10's nil case (%blob) is a subset of v9's (%blob, %active-channel), so returning early on v2 cannot skip a v1 send Verified on a moon: an old %group-action-4 poke applies through the v5 handler, and blob still reaches /v3 while /v2 stays blob-free. Co-Authored-By: Claude Fable 5 --- desk/app/contacts.hoon | 2 +- desk/app/groups-ui.hoon | 1 - desk/app/groups.hoon | 29 +++------ desk/lib/mark-warmer.hoon | 126 -------------------------------------- 4 files changed, 10 insertions(+), 148 deletions(-) delete mode 100644 desk/lib/mark-warmer.hoon diff --git a/desk/app/contacts.hoon b/desk/app/contacts.hoon index 28fa6f7093..e93fc37650 100644 --- a/desk/app/contacts.hoon +++ b/desk/app/contacts.hoon @@ -13,7 +13,7 @@ /+ *contacts, kol :: :: performance, keep warm -/+ j0=contacts-json-0, j1=contacts-json-1, mark-warmer +/+ j0=contacts-json-0, j1=contacts-json-1 :: |% :: conventions diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon index 10fa74ebcc..281cab3b3b 100644 --- a/desk/app/groups-ui.hoon +++ b/desk/app/groups-ui.hoon @@ -2,7 +2,6 @@ a=activity, av=activity-ver /+ default-agent, dbug, verb, vita-client :: performance, keep warm -/+ mark-warmer ^- agent:gall => |% diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index 246b896dbd..e997dbeb1b 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -54,6 +54,7 @@ %group-response-2 %group-response-3 %group-action-3 + %group-action-4 %group-action-5 %group-channel-active %group-channel-join @@ -353,20 +354,7 @@ :: %group-action-4 =+ !<(=a-groups:v8:gv vase) - ?> from-self - ?- -.a-groups - %group - =/ group-core (go-abed:go-core flag.a-groups) - go-abet:(go-a-group:group-core a-group.a-groups) - :: - %invite - =/ group-core (go-abed:go-core flag.a-groups) - go-abet:(go-a-invite:group-core [ships a-invite]:a-groups) - :: - %leave - =/ group-core (go-abed:go-core flag.a-groups) - go-abet:(go-leave:group-core &) - == + $(+< group-action-5+!>(`a-groups:v11:gv`a-groups)) :: :: deprecated %group-action-3 @@ -1314,7 +1302,7 @@ ?- ver.pole %v0 ``groups+(~(run by groups-9) v2:group:v9:gc) %v1 ``groups-1+(~(run by groups-9) v5:group:v9:gc) - %v2 ``groups-2+`groups:v9:gv`groups-9 + %v2 ``groups-2+groups-9 %v3 ``groups-3+groups-11 == :: @@ -1384,7 +1372,8 @@ ``group-2+(v9:group:v11:gc +.u.net-group) :: %v3 - ``group-3+`group:v11:gv`+.u.net-group + =/ gr +.u.net-group + ``group-3+gr == :: [%x ver=?(%v0 %v1 %v2 %v3) %ui %groups ship=@ name=@ rest=*] @@ -4695,10 +4684,10 @@ :: v2 response: %blob is v3-only :: =/ r-group-10=(unit r-group:v10:gv) (v10:r-group:v11:gc r-group) - =? cor ?=(^ r-group-10) - =/ r-groups-10=r-groups:v10:gv [flag u.r-group-10] - =/ v2-paths ~[/v2/groups [%v2 go-area]] - (give %fact v2-paths group-response-2+r-groups-10) + ?~ r-group-10 go-core + =/ r-groups-10=r-groups:v10:gv [flag u.r-group-10] + =/ v2-paths ~[/v2/groups [%v2 go-area]] + =. cor (give %fact v2-paths group-response-2+r-groups-10) :: v1 response: %blob and %active-channel are absent :: =/ r-group-9=(unit r-group:v9:gv) (v9:r-group:v11:gc r-group) diff --git a/desk/lib/mark-warmer.hoon b/desk/lib/mark-warmer.hoon deleted file mode 100644 index 36676a62b8..0000000000 --- a/desk/lib/mark-warmer.hoon +++ /dev/null @@ -1,126 +0,0 @@ -/$ init %ui-init %json -/$ init-1 %ui-init-1 %json -/$ init-2 %ui-init-2 %json -/$ init-3 %ui-init-3 %json -/$ init-4 %ui-init-4 %json -/$ init-5 %ui-init-5 %json -/$ init-6 %ui-init-6 %json -/$ init-7 %ui-init-7 %json -/$ init-9 %ui-init-9 %json -/$ heads %ui-heads %json -/$ heads-2 %ui-heads-2 %json -/$ heads-3 %ui-heads-3 %json -/$ heads-4 %ui-heads-4 %json -/$ pins %ui-pins %json -/$ groups %groups %json -/$ group %group %json -/$ group-ui %group-ui %json -/$ groups-ui %groups-ui %json -/$ gangs %gangs %json -/$ ships %ships %json -/$ flags %flags %json -/$ channels %channels %json -/$ channels-2 %channels-2 %json -/$ channels-3 %channels-3 %json -/$ perm %channel-perm %json -/$ scan %channel-scan %json -/$ unreads %channel-unreads %json -/$ heads %channel-heads %json -/$ heads %channel-heads-2 %json -/$ posts %channel-posts %json -/$ post %channel-post %json -/$ post-2 %channel-post-2 %json -/$ replies %channel-replies %json -/$ replies-2 %channel-replies-2 %json -/$ reply %channel-reply %json -/$ response %channel-response %json -/$ response-1 %channel-response-1 %json -/$ response-2 %channel-response-2 %json -/$ response-3 %channel-response-3 %json -/$ response-4 %channel-response-4 %json -/$ response-5 %channel-response-5 %json -/$ simple-posts %channel-simple-posts %json -/$ simple-post %channel-simple-post %json -/$ simple-replies %channel-simple-replies %json -/$ simple-reply %channel-simple-reply %json -/$ hidden %hidden-posts %json -/$ ch-unreads %chat-unreads %json -/$ writ %writ %json -/$ writ-response %writ-response %json -/$ writ-response-2 %writ-response-2 %json -/$ writ-response-3 %writ-response-3 %json -/$ writ-response-4 %writ-response-4 %json -/$ writs %chat-paged-writs %json -/$ writs-2 %chat-paged-writs-2 %json -/$ writs-3 %chat-paged-writs-3 %json -/$ writs-4 %chat-paged-writs-4 %json -/$ clubs %clubs %json -/$ ch-hidden %hidden-messages %json -/$ c-scan %chat-scan %json -/$ c-scan-2 %chat-scan-2 %json -/$ c-scan-3 %chat-scan-3 %json -/$ c-scan-4 %chat-scan-4 %json -/$ c-scam %chat-scam %json -/$ c-scam-2 %chat-scam-2 %json -/$ c-scam-3 %chat-scam-3 %json -/$ c-scam-4 %chat-scam-4 %json -/$ c-heads %chat-heads %json -/$ c-heads-2 %chat-heads-2 %json -/$ c-heads-3 %chat-heads-3 %json -/$ c-heads-4 %chat-heads-4 %json -/$ act-full-4 %activity-full-4 %json -/$ act-update-4 %activity-update-4 %json -/$ act-summary %activity-summary %json -/$ act-summary-4 %activity-summary-4 %json -/$ act-settings %activity-settings %json -/$ act-event %activity-event %json -/$ act-allowed %activity-allowed %json -/$ act-stream %activity-stream %json -/$ act-feed %activity-feed %json -/$ act-feed-5 %activity-feed-5 %json -/$ act-feed-init %activity-feed-init %json -/$ act-feed-init-5 %activity-feed-init-5 %json -/$ lanyard-update %lanyard-update-1 %json -/$ group-preview %group-preview %json -/$ foreigns-1 %foreigns-1 %json -/$ group-response %group-response-1 %json -/$ group-response-3 %group-response-3 %json -/$ group-ui-2 %group-ui-2 %json -/$ group-ui-3 %group-ui-3 %json -/$ full-info-1 %activity-full-1 %json -/$ full-info-4 %activity-full-4 %json -/$ act-update-1 %activity-update-1 %json -/$ act-update-4 %activity-update-4 %json -/$ act-sum-1 %activity-summary-1 %json -/$ act-sum-4 %activity-summary-4 %json -/$ chat-dif-1 %chat-dm-diff-1 %json -/$ chat-dif-1 %chat-dm-diff-2 %json -/$ c-changed %channel-changed-posts %json -/$ chat-changed %chat-changed-writs %json -/$ group-changed %group-changed-groups-2 %json -/$ group-changed-3 %group-changed-groups-3 %json -/$ contact-changed %contact-changed-pages %json -/$ reel-bait %reel-bait %json -/$ activity-allowed %activity-allowed %json -/$ contact-response-0 %contact-response-0 %json -:: -/$ contact-action %json %contact-action -/$ contact-action-1 %json %contact-action-1 -/$ group-join %json %group-join -/$ group-leave %json %group-leave -/$ group-create-thread %json %group-create-thread -/$ ui-action %json %ui-action -/$ ui-hide-contact %json %ui-hide-contact -/$ grouper-enable %json %grouper-enable -/$ reel-describe %json %reel-describe -/$ notify-client-action %json %notify-client-action -/$ group-action-4 %json %group-action-4 -/$ group-action-5 %json %group-action-5 -/$ activity-action %json %activity-action -/$ activity-action-1 %json %activity-action-1 -/$ channel-action-1 %json %channel-action-1 -/$ channel-action-2 %json %channel-action-2 -/$ chat-dm-action-2 %json %chat-dm-action-2 -/$ chat-dm-diff-2 %json %chat-dm-diff-2 -/$ chat-club-action-2 %json %chat-club-action-2 -~ From 643ea9aeac45b4d1a988ab51916c43dfe34959e7 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 4 Aug 2026 14:56:14 -0500 Subject: [PATCH 18/25] e2e: regenerate test piers with the group blob desk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zod, ten and mug are re-archived from ships running this branch's desk, so the e2e harnesses can talk to a client that reads /v3 with no fallback. Verified the archives carry it: mar/group-3, mar/group/action-5 and blob=(unit @t) in sur/groups.hoon. ~bus stays at rube-bus9, still deliberately outdated for protocol mismatch testing. Named after what they contain rather than a position in the numbered lineage, via a new ARCHIVE_TAG override in archive-piers.sh. Note the tag is now required for the next bump: the script errors out rather than silently minting rube-zod1.tgz when it meets a non-numeric manifest entry. Also updates the bot e2e compose, which archive-piers.sh does not touch and which is what Shared E2E actually reads — including the pinned md5 and byte size for each ship, cross-checked against gsutil stat. Old archives stay in the bucket, so openclaw's dev compose and its rube-27-calibrated gateway-status test keep working untouched. Co-Authored-By: Claude Fable 5 --- apps/tlon-web/e2e/shipManifest.json | 6 ++--- apps/tlon-web/rube/Dockerfile | 6 ++--- apps/tlon-web/rube/archive-piers.sh | 16 ++++++++++++- .../docker/docker-compose.base.yml | 24 +++++++++---------- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/tlon-web/e2e/shipManifest.json b/apps/tlon-web/e2e/shipManifest.json index 7a2cce1fb6..37844e8483 100644 --- a/apps/tlon-web/e2e/shipManifest.json +++ b/apps/tlon-web/e2e/shipManifest.json @@ -1,7 +1,7 @@ { "~zod": { "authFile": "e2e/.auth/zod.json", - "downloadUrl": "https://bootstrap.urbit.org/rube-zod27.tgz", + "downloadUrl": "https://bootstrap.urbit.org/rube-zod-group-blob.tgz", "url": "http://localhost:35453", "ship": "zod", "code": "lidlut-tabwed-pillex-ridrup", @@ -28,7 +28,7 @@ }, "~ten": { "authFile": "e2e/.auth/ten.json", - "downloadUrl": "https://bootstrap.urbit.org/rube-ten27.tgz", + "downloadUrl": "https://bootstrap.urbit.org/rube-ten-group-blob.tgz", "url": "http://localhost:38473", "ship": "ten", "code": "lapseg-nolmel-riswen-hopryc", @@ -41,7 +41,7 @@ }, "~mug": { "authFile": "e2e/.auth/mug.json", - "downloadUrl": "https://bootstrap.urbit.org/rube-mug27.tgz", + "downloadUrl": "https://bootstrap.urbit.org/rube-mug-group-blob.tgz", "url": "http://localhost:39983", "ship": "mug", "code": "ravsut-bolryd-hapsum-pastul", diff --git a/apps/tlon-web/rube/Dockerfile b/apps/tlon-web/rube/Dockerfile index 20c59c6edf..3994651e3f 100644 --- a/apps/tlon-web/rube/Dockerfile +++ b/apps/tlon-web/rube/Dockerfile @@ -69,10 +69,10 @@ WORKDIR /workspace RUN mkdir -p /opt/ships && cd /opt/ships && \ echo "Downloading ship archives and Urbit binary in parallel..." && \ printf "%s\n" \ - "https://bootstrap.urbit.org/rube-zod27.tgz zod.tgz" \ + "https://bootstrap.urbit.org/rube-zod-group-blob.tgz zod.tgz" \ "https://bootstrap.urbit.org/rube-bus9.tgz bus.tgz" \ - "https://bootstrap.urbit.org/rube-ten27.tgz ten.tgz" \ - "https://bootstrap.urbit.org/rube-mug27.tgz mug.tgz" \ + "https://bootstrap.urbit.org/rube-ten-group-blob.tgz ten.tgz" \ + "https://bootstrap.urbit.org/rube-mug-group-blob.tgz mug.tgz" \ "https://github.com/urbit/vere/releases/latest/download/linux-x86_64.tgz urbit.tgz" \ | xargs -P 5 -n 2 sh -c 'curl -fSL -o "$1" "$0" || exit 255' && \ echo "All downloads complete, extracting..." && \ diff --git a/apps/tlon-web/rube/archive-piers.sh b/apps/tlon-web/rube/archive-piers.sh index 5016fa8329..be299f8d47 100755 --- a/apps/tlon-web/rube/archive-piers.sh +++ b/apps/tlon-web/rube/archive-piers.sh @@ -28,6 +28,7 @@ SKIP_MELD=${SKIP_MELD:-false} VERIFY_AFTER_UPLOAD=${VERIFY_AFTER_UPLOAD:-false} FRESH_BOOT=${FRESH_BOOT:-false} SKIP_PREPARE=${SKIP_PREPARE:-false} +ARCHIVE_TAG=${ARCHIVE_TAG:-} # Parse command line arguments for arg in "$@"; do @@ -59,6 +60,7 @@ for arg in "$@"; do echo " SKIP_CLEANUP=true Keep local archives after upload" echo " SKIP_MELD=true Skip meld operation (for low-memory systems)" echo " SKIP_PREPARE=true Skip ship prep/re-extraction (same as --skip-prepare)" + echo " ARCHIVE_TAG=-tag Name archives rube-{ship}{tag}.tgz instead of bumping the number" echo " URBIT_BINARY=PATH Urbit binary to use for sync/roll/chop (must run on this host)" echo "" exit 0 @@ -123,10 +125,22 @@ get_current_version() { echo "$url" | sed -n 's/.*rube-'"$ship"'\([0-9]*\)\.tgz/\1/p' } -# Function to increment version +# Function to increment version, or use a literal tag when pinned. +# ARCHIVE_TAG names archives after what they contain rather than a position in +# the numbered lineage (e.g. ARCHIVE_TAG=-group-blob -> rube-zod-group-blob.tgz), +# for piers tied to a branch. get_next_version() { local ship=$1 + if [ -n "$ARCHIVE_TAG" ]; then + echo "$ARCHIVE_TAG" + return 0 + fi local current_version=$(get_current_version "$ship") + if [ -z "$current_version" ]; then + print_error "Cannot derive a version for ~$ship from $MANIFEST_FILE" + print_info "The manifest holds a non-numeric archive name; set ARCHIVE_TAG explicitly." + return 1 + fi echo $((current_version + 1)) } diff --git a/packages/tlon-bot-e2e/docker/docker-compose.base.yml b/packages/tlon-bot-e2e/docker/docker-compose.base.yml index e11c0944cd..54de024b84 100644 --- a/packages/tlon-bot-e2e/docker/docker-compose.base.yml +++ b/packages/tlon-bot-e2e/docker/docker-compose.base.yml @@ -130,23 +130,23 @@ services: } zod_archive="$$(artifact \ - rube-zod27.tgz \ - https://bootstrap.urbit.org/rube-zod27.tgz \ + rube-zod-group-blob.tgz \ + https://bootstrap.urbit.org/rube-zod-group-blob.tgz \ md5 \ - 6e3f322f441fcd942590470b10b7cea5 \ - 283602626)" + 54eddbbd4ba4aea4dfddf32572696e4a \ + 339451899)" ten_archive="$$(artifact \ - rube-ten27.tgz \ - https://bootstrap.urbit.org/rube-ten27.tgz \ + rube-ten-group-blob.tgz \ + https://bootstrap.urbit.org/rube-ten-group-blob.tgz \ md5 \ - b3ec907c2a851cff05a4676806836ded \ - 283381725)" + 665a73f4eb2e910abe3b0f2c156f1ad6 \ + 338626853)" mug_archive="$$(artifact \ - rube-mug27.tgz \ - https://bootstrap.urbit.org/rube-mug27.tgz \ + rube-mug-group-blob.tgz \ + https://bootstrap.urbit.org/rube-mug-group-blob.tgz \ md5 \ - b378f62ec7a95a243d5d88976193ad1a \ - 284181858)" + 90ec6c8c6640dab832358bf4fe30b8c7 \ + 339572369)" vere_archive="$$(artifact \ linux-x86_64-vere-v4.5.tgz \ https://github.com/urbit/vere/releases/download/vere-v4.5/linux-x86_64.tgz \ From 408ada2a5363d11cf6292f4a995dc4f3117b092b Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 4 Aug 2026 11:24:49 -0500 Subject: [PATCH 19/25] groups: delete the dead light/groups and plural ui/groups scries Neither surface has a consumer: nothing anywhere reads /x/vN/light/groups or the deprecated /x/groups/light alias, and the only reference to the plural /x/vN/ui/groups map was a request-timeout test using the path as arbitrary filler (the in-desk create threads read the per-flag path, which stays). Rather than carry them forward to v3, remove the peek arms and their scry declarations. Repoints that timeout test at /v3/groups so it does not name a deleted endpoint. Leaves the now-unproduced %groups-ui{,-1,-2} marks in place: dropping a strict mark is a one-way door and keeping them costs nothing. Co-Authored-By: Claude Fable 5 --- desk/app/groups.hoon | 36 ------------------- .../src/__tests__/urbitRequestTimeout.test.ts | 2 +- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index e997dbeb1b..c63ec848bc 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -107,15 +107,6 @@ [/x/v2/groups/$/$/channels/$/$/$/readers %ships] [/x/v2/groups/$/$/channels/$/$/$/writers %ships] [/x/groups/$/$/seats/$ %noun] - :: - [/x/groups/light %groups] - [/x/v0/light/groups %groups] - [/x/v1/light/groups %groups-1] - [/x/v2/light/groups %groups-2] - :: - [/x/v0/ui/groups %groups-ui] - [/x/v1/ui/groups %groups-ui-1] - [/x/v2/ui/groups %groups-ui-2] :: [/x/v0/ui/groups/$/$ %group-ui] [/x/v1/ui/groups/$/$ %group-ui-1] @@ -1305,36 +1296,9 @@ %v2 ``groups-2+groups-9 %v3 ``groups-3+groups-11 == - :: - [%x ver=?(%v0 %v1 %v2) %light %groups ~] - =/ groups-9=groups:v9:gv - %- ~(run by groups) - |= [=net:g =group:g] - (v9:group:v11:gc (drop-seats:group:v11:gc group our.bowl)) - ?- ver.pole - %v0 ``groups+(~(run by groups-9) v2:group:v9:gc) - %v1 ``groups-1+(~(run by groups-9) v5:group:v9:gc) - %v2 ``groups-2+groups-9 - == - :: - [%x ver=?(%v0 %v1 %v2) %ui %groups ~] - ?- ver.pole - %v0 - =- ``groups-ui+- - %- ~(urn by groups) - |= [=flag:g =net:g =group:g] - =/ =status:neg - (read-status:neg bowl [p.flag %groups]) - (group-ui:v2:group:v11:gc status net group) - :: - %v1 ``groups-ui-1+(~(run by groups) group-ui:v5:group:v11:gc) - %v2 ``groups-ui-2+(~(run by groups) group-ui:v9:group:v11:gc) - == :: :: deprecated [%x %groups ~] $(pole /x/v0/groups) - :: deprecated - [%x %groups %light ~] $(pole /x/v0/light/groups) :: [%x %v1 %changes since=@ rest=*] =+ since=(slav %da since.pole) diff --git a/packages/api/src/__tests__/urbitRequestTimeout.test.ts b/packages/api/src/__tests__/urbitRequestTimeout.test.ts index 1bcf33691a..a78d804dde 100644 --- a/packages/api/src/__tests__/urbitRequestTimeout.test.ts +++ b/packages/api/src/__tests__/urbitRequestTimeout.test.ts @@ -104,7 +104,7 @@ describe('Urbit request timeouts cover the response body read', () => { it('scry rejects when the response body stalls after headers', async () => { const client = new Urbit('', undefined, 'groups', stalledBodyFetch()); await expect( - client.scry({ app: 'groups', path: '/v2/ui/groups', timeout: 50 }) + client.scry({ app: 'groups', path: '/v3/groups', timeout: 50 }) ).rejects.toThrow(); }); From 6692f1f6f326a39cdc3b027a7e1596bb0ce3cf0c Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 4 Aug 2026 15:47:33 -0500 Subject: [PATCH 20/25] test(openclaw): scry gateway liveness from %steward, not %gateway-status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 08's first readStatus() died with a terminal 404 as soon as this branch regenerated the e2e piers. The scry it reads moved months ago: the steward refactor (3b4d55b8a and follow-ups) relocated gateway liveness state into %steward's gateway module and reduced %gateway-status to a poke-only proxy whose on-peek is a stub. The case only kept passing because it was reading a rube-27 pier baked from a pre-refactor desk, so any pier refresh would have surfaced this. Point the read path at %steward /x/v1/gateway/status. The write path is untouched — the plugin still pokes %gateway-status-action-1, which the proxy forwards on as steward-gateway-action-1. decodeGatewayStatus needs no change: ga-peek serves the same [status (unit @da)] tuple. Verified against a live ship on the current desk — /v1/gateway/status cues and decodes to {status, leaseUntil}, /v1/gateway/owner-activity decodes via decodeDa, and the old %gateway-status path returns the same terminal 404 seen in CI. Also moves openclaw's dev compose to the group-blob archives. Both it and tlon-bot-e2e's compose run test/cases/, so leaving dev on rube-27 would trade a CI failure for a `pnpm test:integration` failure. md5s and byte sizes cross-checked against the x-goog-hash headers on bootstrap.urbit.org. Co-Authored-By: Claude Opus 5 (1M context) --- packages/openclaw/dev/docker-compose.test.yml | 28 +++++++++++-------- .../test/cases/08-gateway-status.test.ts | 23 +++++++++++---- packages/openclaw/test/lib/noun-scry.ts | 4 +-- packages/tlon-bot-e2e/README.md | 2 +- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/openclaw/dev/docker-compose.test.yml b/packages/openclaw/dev/docker-compose.test.yml index fa13f812c6..6335163182 100644 --- a/packages/openclaw/dev/docker-compose.test.yml +++ b/packages/openclaw/dev/docker-compose.test.yml @@ -89,27 +89,31 @@ services: # Pin both halves of the runtime/snapshot pair so the version skew that # crashed serf replay (stale snapshot -> downgrade runtime to replay -> # serf unexpectedly shut down) cannot silently return when `latest` - # advances past the snapshot kelvin. rube-27 is the archive generation - # the tlon-web e2e suite runs; vere-v4.5 is the matching runtime. - echo "==> vere pin: vere-v4.5 pier gen: 27" + # advances past the snapshot kelvin. rube-*-group-blob is the archive + # generation the tlon-web e2e suite runs; vere-v4.5 is the matching + # runtime. Keep this in lockstep with + # packages/tlon-bot-e2e/docker/docker-compose.base.yml: both harnesses + # run test/cases/, and case 08 scries %steward, which only exists on + # piers built from a post-steward-refactor desk. + echo "==> vere pin: vere-v4.5 pier gen: group-blob" echo "==> Downloading zod..." - download https://bootstrap.urbit.org/rube-zod27.tgz rube-zod27.tgz md5 6e3f322f441fcd942590470b10b7cea5 283602626 || { echo "Failed to download zod"; exit 1; } - ls -la rube-zod27.tgz + download https://bootstrap.urbit.org/rube-zod-group-blob.tgz rube-zod-group-blob.tgz md5 54eddbbd4ba4aea4dfddf32572696e4a 339451899 || { echo "Failed to download zod"; exit 1; } + ls -la rube-zod-group-blob.tgz echo "==> Extracting zod..." - tar xzf rube-zod27.tgz || { echo "Failed to extract zod"; exit 1; } + tar xzf rube-zod-group-blob.tgz || { echo "Failed to extract zod"; exit 1; } echo "==> Downloading ten..." - download https://bootstrap.urbit.org/rube-ten27.tgz rube-ten27.tgz md5 b3ec907c2a851cff05a4676806836ded 283381725 || { echo "Failed to download ten"; exit 1; } - ls -la rube-ten27.tgz + download https://bootstrap.urbit.org/rube-ten-group-blob.tgz rube-ten-group-blob.tgz md5 665a73f4eb2e910abe3b0f2c156f1ad6 338626853 || { echo "Failed to download ten"; exit 1; } + ls -la rube-ten-group-blob.tgz echo "==> Extracting ten..." - tar xzf rube-ten27.tgz || { echo "Failed to extract ten"; exit 1; } + tar xzf rube-ten-group-blob.tgz || { echo "Failed to extract ten"; exit 1; } echo "==> Downloading mug..." - download https://bootstrap.urbit.org/rube-mug27.tgz rube-mug27.tgz md5 b378f62ec7a95a243d5d88976193ad1a 284181858 || { echo "Failed to download mug"; exit 1; } - ls -la rube-mug27.tgz + download https://bootstrap.urbit.org/rube-mug-group-blob.tgz rube-mug-group-blob.tgz md5 90ec6c8c6640dab832358bf4fe30b8c7 339572369 || { echo "Failed to download mug"; exit 1; } + ls -la rube-mug-group-blob.tgz echo "==> Extracting mug..." - tar xzf rube-mug27.tgz || { echo "Failed to extract mug"; exit 1; } + tar xzf rube-mug-group-blob.tgz || { echo "Failed to extract mug"; exit 1; } echo "==> Downloading vere..." download https://github.com/urbit/vere/releases/download/vere-v4.5/linux-x86_64.tgz vere.tgz sha256 bf7a30aad0a820bf217f3f81239b1029d16f11ba5274d45067d811489f817eed 9728849 || { echo "Failed to download vere"; exit 1; } diff --git a/packages/openclaw/test/cases/08-gateway-status.test.ts b/packages/openclaw/test/cases/08-gateway-status.test.ts index a283a4ff27..a02bbf4b03 100644 --- a/packages/openclaw/test/cases/08-gateway-status.test.ts +++ b/packages/openclaw/test/cases/08-gateway-status.test.ts @@ -3,9 +3,20 @@ * * Test 1 is the sensitivity guard: on prewarming cores, a forced Tlon monitor * restart must establish a new lease and then renew it from the replacement - * monitor. Test 2 is omitted because rube-27's archived activity feed does not - * advance the old agent's owner-activity; a regenerated steward pier can cover - * it later. + * monitor. + * + * The read path points at %steward, not %gateway-status. The steward refactor + * moved liveness state into steward's gateway module and reduced + * %gateway-status to a poke-only proxy that forwards gateway-status-action-1 + * on to %steward — its own on-peek is a stub. So the plugin still POKES + * %gateway-status (see @tloncorp/api gatewayStatusApi) while this test SCRIES + * %steward at /x/v1/gateway/status. The payload is byte-identical to what the + * standalone agent used to serve, so decodeGatewayStatus is unchanged. + * + * An owner-activity assertion (/v1/gateway/owner-activity, decodeDa) is still + * unwritten. It was previously blocked on rube-27's archived activity feed not + * advancing the old agent's owner-activity; the piers now carry a steward that + * would serve it. */ import { beforeAll, describe, expect, test } from 'vitest'; @@ -20,7 +31,7 @@ import { setGatewayStatusRestartConfig, } from '../lib/index.js'; -const ARCHIVE = 'pinned rube-zod27'; +const ARCHIVE = 'pinned rube-zod-group-blob'; // Must match dev/Dockerfile.test's ARG OPENCLAW_CORE_VERSION default: the test // asserts the container's installed core equals this requested version. Bumping // both together to 2026.6.11/2026.7.1 (already in the known-prewarm map below) @@ -31,8 +42,8 @@ const INTERNAL_TEST_TIMEOUT_MS = 165_000; const DIAGNOSTIC_RESERVE_MS = 12_000; const MIN_POLL_OPERATION_BUDGET_MS = 2_000; const STATUS_SCRY = { - app: 'gateway-status', - path: '/status', + app: 'steward', + path: '/v1/gateway/status', archive: ARCHIVE, } as const; const GATEWAY_START = '[gateway-status] gateway_start received (generation=1)'; diff --git a/packages/openclaw/test/lib/noun-scry.ts b/packages/openclaw/test/lib/noun-scry.ts index 708034fe65..2cc7f821a3 100644 --- a/packages/openclaw/test/lib/noun-scry.ts +++ b/packages/openclaw/test/lib/noun-scry.ts @@ -179,7 +179,7 @@ function malformedGatewayStatus(noun: Noun, reason: string): never { ); } -/** Decode the old standalone agent's `[status (unit @da)]` noun. */ +/** Decode steward's `/x/v1/gateway/status` `[status (unit @da)]` noun. */ export function decodeGatewayStatus(noun: Noun): GatewayStatusScry { const raw = noun.toString(); if (!(noun instanceof Cell)) { @@ -222,7 +222,7 @@ export function decodeGatewayStatus(noun: Noun): GatewayStatusScry { return { status, leaseUntil, raw }; } -/** Decode a bare @da noun, used by the standalone owner-activity scry. */ +/** Decode a bare @da noun, as served by `/x/v1/gateway/owner-activity`. */ export function decodeDa(noun: Noun): number { if (!(noun instanceof Atom)) { throw new Error( diff --git a/packages/tlon-bot-e2e/README.md b/packages/tlon-bot-e2e/README.md index 3a9bd72ece..05a3438c7f 100644 --- a/packages/tlon-bot-e2e/README.md +++ b/packages/tlon-bot-e2e/README.md @@ -125,7 +125,7 @@ The Hermes E2E compose path does not load `packages/hermes-tlon-adapter/.env`. I - empty `mcp_servers` - `agent.disabled_toolsets: [cronjob]` -The fake ship service preserves the current rube-27 / `vere-v4.5` runtime pairing and deterministic fakezod access codes. Startup logs include artifact URLs, cache hit/miss state, byte sizes, and checksum results. The rube archive checks use the MD5 ETags exposed by `bootstrap.urbit.org`; the Vere archive uses a checked SHA-256. +The fake ship service preserves the current rube-\*-group-blob / `vere-v4.5` runtime pairing and deterministic fakezod access codes. Startup logs include artifact URLs, cache hit/miss state, byte sizes, and checksum results. The rube archive checks use the MD5 ETags exposed by `bootstrap.urbit.org`; the Vere archive uses a checked SHA-256. ## Unit Tests From a085007743f89d9ab69493f7c1c2607cd798c9de Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 4 Aug 2026 16:45:39 -0500 Subject: [PATCH 21/25] address Codex review: no-op blob poke, archive script guards updateGroupBlob: the host's %blob arm emits nothing when the value is unchanged, so trackedPoke waited out its 20s timeout and then rolled back a write that was already correct. Return early instead. Adds tests covering unchanged, both-empty, changed and cleared; the two no-op cases fail without the guard. archive-piers.sh: the version guard added with ARCHIVE_TAG never fired. print_error/print_info write to stdout, and `local x=$(f)` returns the status of local rather than f, so the caller captured the diagnostics as the version string and would have built an archive name out of the error text. Redirect those two prints to stderr, matching archive_pier's existing convention, and split the declaration from the assignment so the failure aborts. update_dockerfile matched only rube-{ship}[0-9]*.tgz. That worked for the numeric-to-tagged transition but would silently skip a second tagged run, leaving the Dockerfile on a stale tarball. Match any suffix. Co-Authored-By: Claude Fable 5 --- apps/tlon-web/rube/archive-piers.sh | 17 ++-- .../shared/src/store/groupActions.test.ts | 77 +++++++++++++++++++ packages/shared/src/store/groupActions.ts | 7 ++ 3 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/store/groupActions.test.ts diff --git a/apps/tlon-web/rube/archive-piers.sh b/apps/tlon-web/rube/archive-piers.sh index be299f8d47..0de6857bd3 100755 --- a/apps/tlon-web/rube/archive-piers.sh +++ b/apps/tlon-web/rube/archive-piers.sh @@ -137,8 +137,8 @@ get_next_version() { fi local current_version=$(get_current_version "$ship") if [ -z "$current_version" ]; then - print_error "Cannot derive a version for ~$ship from $MANIFEST_FILE" - print_info "The manifest holds a non-numeric archive name; set ARCHIVE_TAG explicitly." + print_error "Cannot derive a version for ~$ship from $MANIFEST_FILE" >&2 + print_info "The manifest holds a non-numeric archive name; set ARCHIVE_TAG explicitly." >&2 return 1 fi echo $((current_version + 1)) @@ -924,9 +924,9 @@ update_dockerfile() { # Pattern: "https://bootstrap.urbit.org/rube-SHIP*.tgz SHIP.tgz" # macOS sed requires empty string after -i, GNU sed works with or without if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|https://bootstrap.urbit.org/rube-${ship}[0-9]*.tgz ${ship}.tgz|${new_url} ${ship}.tgz|g" "$DOCKERFILE" + sed -i '' "s|https://bootstrap.urbit.org/rube-${ship}[^ ]*\.tgz ${ship}.tgz|${new_url} ${ship}.tgz|g" "$DOCKERFILE" else - sed -i "s|https://bootstrap.urbit.org/rube-${ship}[0-9]*.tgz ${ship}.tgz|${new_url} ${ship}.tgz|g" "$DOCKERFILE" + sed -i "s|https://bootstrap.urbit.org/rube-${ship}[^ ]*\.tgz ${ship}.tgz|${new_url} ${ship}.tgz|g" "$DOCKERFILE" fi print_status "Updated Dockerfile for ~$ship" @@ -1032,8 +1032,13 @@ main() { exit 1 fi - # Get next version number - local next_version=$(get_next_version "$ship") + # Get next version number. Split the declaration from the assignment + # so a failure in get_next_version is not masked by `local`. + local next_version + if ! next_version=$(get_next_version "$ship"); then + print_error "Aborting: no archive version available for ~$ship" + exit 1 + fi print_info "Next version for $ship: $next_version" # Archive the pier diff --git a/packages/shared/src/store/groupActions.test.ts b/packages/shared/src/store/groupActions.test.ts new file mode 100644 index 0000000000..1f803c1a74 --- /dev/null +++ b/packages/shared/src/store/groupActions.test.ts @@ -0,0 +1,77 @@ +import * as api from '@tloncorp/api'; +import { afterEach, expect, test, vi } from 'vitest'; + +import * as schema from '../db/schema'; +import { getClient, setupDatabaseTestSuite } from '../test/helpers'; +import { updateGroupBlob } from './groupActions'; + +setupDatabaseTestSuite(); + +const groupId = '~bus/blob-actions'; + +async function insertGroup(blob: string | null) { + const client = getClient(); + if (!client) throw new Error('test db not initialized'); + + await client.insert(schema.groups).values({ + id: groupId, + currentUserIsMember: true, + currentUserIsHost: true, + hostUserId: '~bus', + blob, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// The host's %blob arm short-circuits when the value is unchanged, emitting no +// update, so a tracked poke would wait out its 20s timeout and then roll back a +// write that was already correct. The action must not poke at all. +test('updateGroupBlob does not poke when the blob is unchanged', async () => { + await insertGroup('{"k":1}'); + const updateGroupBlobApi = vi + .spyOn(api, 'updateGroupBlob') + .mockResolvedValue(undefined as never); + + await updateGroupBlob({ id: groupId } as never, '{"k":1}'); + + expect(updateGroupBlobApi).not.toHaveBeenCalled(); +}); + +test('updateGroupBlob does not poke when both sides are empty', async () => { + await insertGroup(null); + const updateGroupBlobApi = vi + .spyOn(api, 'updateGroupBlob') + .mockResolvedValue(undefined as never); + + await updateGroupBlob({ id: groupId } as never, null); + + expect(updateGroupBlobApi).not.toHaveBeenCalled(); +}); + +test('updateGroupBlob pokes when the blob changes', async () => { + await insertGroup('{"k":1}'); + const updateGroupBlobApi = vi + .spyOn(api, 'updateGroupBlob') + .mockResolvedValue(undefined as never); + + await updateGroupBlob({ id: groupId } as never, '{"k":2}'); + + expect(updateGroupBlobApi).toHaveBeenCalledWith({ + groupId, + blob: '{"k":2}', + }); +}); + +test('updateGroupBlob pokes when clearing an existing blob', async () => { + await insertGroup('{"k":1}'); + const updateGroupBlobApi = vi + .spyOn(api, 'updateGroupBlob') + .mockResolvedValue(undefined as never); + + await updateGroupBlob({ id: groupId } as never, null); + + expect(updateGroupBlobApi).toHaveBeenCalledWith({ groupId, blob: null }); +}); diff --git a/packages/shared/src/store/groupActions.ts b/packages/shared/src/store/groupActions.ts index 928cbdd405..5d035f7d6e 100644 --- a/packages/shared/src/store/groupActions.ts +++ b/packages/shared/src/store/groupActions.ts @@ -499,6 +499,13 @@ export async function updateGroupBlob( const existingGroup = await db.getGroup({ id: group.id }); + // The host emits no %blob update when the value is unchanged, so the tracked + // poke would wait out its timeout and then roll back a write that was + // already correct. + if (existingGroup && (existingGroup.blob ?? null) === blob) { + return; + } + // optimistic update await db.updateGroup({ id: group.id, blob }); From 0e0d369ee0cf6b4b95dbc2c3b338bf9a556dc19e Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Wed, 5 Aug 2026 17:05:32 -0500 Subject: [PATCH 22/25] groups: keep the light/groups scry, it has live callers Deleting `/x/groups/light` and the `%light %groups` arm broke CI: rube's `checkGroupsAppHealth` probes `/~/scry/groups/groups/light.json` (apps/tlon-web/rube/index.ts:1205), so every ship failed its readiness check and both the parallel E2E job and the production build smoke test timed out in "Setting up ships". Three more live callers depend on the same deprecated alias: - apps/tlon-mobile/ios/Shared/Networking/PocketChatAPI.swift:17 - apps/tlon-mobile/android/.../api/TalkApi.java:137 - api/openapi.yaml:42, documented as /groups/groups/light.json The earlier audit missed them by grepping the versioned spelling (`light/groups`); every real caller uses the alias spelling (`groups/light`). The plural `%ui %groups` arm is still genuinely dead and stays deleted -- its only references were the per-group `/vN/ui/groups/{ship}/{name}` arm, which survives, and a timeout test that used the path arbitrarily. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GGt4ADrmVsoD8mxunqeZxp --- desk/app/groups.hoon | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon index c63ec848bc..611aa7ad05 100644 --- a/desk/app/groups.hoon +++ b/desk/app/groups.hoon @@ -107,6 +107,11 @@ [/x/v2/groups/$/$/channels/$/$/$/readers %ships] [/x/v2/groups/$/$/channels/$/$/$/writers %ships] [/x/groups/$/$/seats/$ %noun] + :: + [/x/groups/light %groups] + [/x/v0/light/groups %groups] + [/x/v1/light/groups %groups-1] + [/x/v2/light/groups %groups-2] :: [/x/v0/ui/groups/$/$ %group-ui] [/x/v1/ui/groups/$/$ %group-ui-1] @@ -1296,9 +1301,22 @@ %v2 ``groups-2+groups-9 %v3 ``groups-3+groups-11 == + :: + [%x ver=?(%v0 %v1 %v2) %light %groups ~] + =/ groups-9=groups:v9:gv + %- ~(run by groups) + |= [=net:g =group:g] + (v9:group:v11:gc (drop-seats:group:v11:gc group our.bowl)) + ?- ver.pole + %v0 ``groups+(~(run by groups-9) v2:group:v9:gc) + %v1 ``groups-1+(~(run by groups-9) v5:group:v9:gc) + %v2 ``groups-2+groups-9 + == :: :: deprecated [%x %groups ~] $(pole /x/v0/groups) + :: deprecated + [%x %groups %light ~] $(pole /x/v0/light/groups) :: [%x %v1 %changes since=@ rest=*] =+ since=(slav %da since.pole) From 27b80d0f2c38563a1a06b69b0d7ecc521b8446f2 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Fri, 7 Aug 2026 18:08:16 -0500 Subject: [PATCH 23/25] groups: move the blob init/changes arms to v10/v11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6188 claims `[%x %v9 %init ~]` and `[%x %v10 %changes ~]` for v10-native activity, and adds its own `mar/ui/init-9.hoon` and `sur/ui.hoon` `init-9`. Blob ships after activity, so it takes the next numbers instead of folding into those arms: /v9/init -> /v10/init init-9 -> init-10 /v10/changes -> /v11/changes %ui-init-9 -> %ui-init-10 GroupsInit9 -> GroupsInit10 ChangesV10 -> ChangesV11 Still on `activity:v8:av` because `v10` does not exist until #6188 merges. That flip is required, not cosmetic: `v9:source:v10:ac` returns `~` for `%note` and `%notebook`, so a v8-sourced init or changes silently drops every note unread. Three sites to change on rebase — `sur/ui.hoon` `init-10`, `groups-ui.hoon` `/v4/activity` in both arms, and `mar/ui/init-10.hoon`'s enjs call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GGt4ADrmVsoD8mxunqeZxp --- desk/app/groups-ui.hoon | 8 ++++---- desk/lib/rail.hoon | 10 +++++----- desk/mar/ui/{init-9.hoon => init-10.hoon} | 4 ++-- desk/sur/ui.hoon | 2 +- packages/api/src/__tests__/initApi.test.ts | 4 ++-- packages/api/src/client/changesApi.ts | 6 +++--- packages/api/src/client/initApi.ts | 6 +++--- packages/api/src/urbit/ui.ts | 4 ++-- packages/shared/src/store/sync/sync.test.ts | 6 +++--- 9 files changed, 25 insertions(+), 25 deletions(-) rename desk/mar/ui/{init-9.hoon => init-10.hoon} (96%) diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon index 281cab3b3b..723bc90721 100644 --- a/desk/app/groups-ui.hoon +++ b/desk/app/groups-ui.hoon @@ -236,13 +236,13 @@ == ``ui-init-8+!>(init) :: - [%x %v9 %init ~] + [%x %v10 %init ~] =+ .^([=groups-ui:v11:gv =foreigns:v8:gv] (scry %gx %groups /v4/init/noun)) =+ .^(channel=channel-10:u (scry %gx %channels /v6/init/noun)) =+ .^(chat=chat-2:u (scry %gx %chat /v1/init/noun)) =+ .^(=activity:v8:av (scry %gx %activity /v4/activity/noun)) =+ .^(profile=? (scry %gx %profile /bound/loob)) - =/ init=init-9:u + =/ init=init-10:u :* groups-ui foreigns channel @@ -251,7 +251,7 @@ chat profile == - ``ui-init-9+!>(init) + ``ui-init-10+!>(init) :: [%x %v5 %changes since=@ ~] =+ .^(activity=json (scry %gx %activity /v4/activity/changes/[since.pole]/json)) @@ -328,7 +328,7 @@ 'contacts'^contacts == :: - [%x %v10 %changes since=@ ~] + [%x %v11 %changes since=@ ~] =+ .^(activity=json (scry %gx %activity /v4/activity/changes/[since.pole]/json)) =+ .^(channels=json (scry %gx %channels /v6/changes/[since.pole]/json)) =+ .^(chat=json (scry %gx %chat /v4/changes/[since.pole]/json)) diff --git a/desk/lib/rail.hoon b/desk/lib/rail.hoon index 95e8f4e9c9..d19fc731aa 100644 --- a/desk/lib/rail.hoon +++ b/desk/lib/rail.hoon @@ -344,7 +344,7 @@ /% ui-init-6 %ui-init-6 /% ui-init-7 %ui-init-7 /% ui-init-8 %ui-init-8 -/% ui-init-9 %ui-init-9 +/% ui-init-10 %ui-init-10 /% ui-pins %ui-pins /% ui-show-contact %ui-show-contact /% ui-vita %ui-vita @@ -704,7 +704,7 @@ $: %ui-init-6 $+ ui-init-6 p=_*vale:ui-init-6 == $: %ui-init-7 $+ ui-init-7 p=_*vale:ui-init-7 == $: %ui-init-8 $+ ui-init-8 p=_*vale:ui-init-8 == - $: %ui-init-9 $+ ui-init-9 p=_*vale:ui-init-9 == + $: %ui-init-10 $+ ui-init-10 p=_*vale:ui-init-10 == $: %ui-pins $+ ui-pins p=_*vale:ui-pins == $: %ui-show-contact $+ ui-show-contact p=_*vale:ui-show-contact == $: %ui-vita $+ ui-vita p=_*vale:ui-vita == @@ -1066,7 +1066,7 @@ %ui-init-6 [-.rail !>(+.rail)] %ui-init-7 [-.rail !>(+.rail)] %ui-init-8 [-.rail !>(+.rail)] - %ui-init-9 [-.rail !>(+.rail)] + %ui-init-10 [-.rail !>(+.rail)] %ui-pins [-.rail !>(+.rail)] %ui-show-contact [-.rail !>(+.rail)] %ui-vita [-.rail !>(+.rail)] @@ -1426,7 +1426,7 @@ %ui-init-6 [p !<(_*vale:ui-init-6 q)] %ui-init-7 [p !<(_*vale:ui-init-7 q)] %ui-init-8 [p !<(_*vale:ui-init-8 q)] - %ui-init-9 [p !<(_*vale:ui-init-9 q)] + %ui-init-10 [p !<(_*vale:ui-init-10 q)] %ui-pins [p !<(_*vale:ui-pins q)] %ui-show-contact [p !<(_*vale:ui-show-contact q)] %ui-vita [p !<(_*vale:ui-vita q)] @@ -1786,7 +1786,7 @@ :- %ui-init-6 -:!>(*vale:ui-init-6) :- %ui-init-7 -:!>(*vale:ui-init-7) :- %ui-init-8 -:!>(*vale:ui-init-8) - :- %ui-init-9 -:!>(*vale:ui-init-9) + :- %ui-init-10 -:!>(*vale:ui-init-10) :- %ui-pins -:!>(*vale:ui-pins) :- %ui-show-contact -:!>(*vale:ui-show-contact) :- %ui-vita -:!>(*vale:ui-vita) diff --git a/desk/mar/ui/init-9.hoon b/desk/mar/ui/init-10.hoon similarity index 96% rename from desk/mar/ui/init-9.hoon rename to desk/mar/ui/init-10.hoon index d347b082f5..21ad3dea7b 100644 --- a/desk/mar/ui/init-9.hoon +++ b/desk/mar/ui/init-10.hoon @@ -1,6 +1,6 @@ /- u=ui /+ gj=groups-json, cj=chat-json, dj=channel-json, aj=activity-json -|_ init=init-9:u +|_ init=init-10:u ++ grad %noun ++ grow |% @@ -33,6 +33,6 @@ -- ++ grab |% - ++ noun init-9:u + ++ noun init-10:u -- -- diff --git a/desk/sur/ui.hoon b/desk/sur/ui.hoon index debf2472ba..4c409da9bb 100644 --- a/desk/sur/ui.hoon +++ b/desk/sur/ui.hoon @@ -1,6 +1,6 @@ /- av=activity-ver, gv=groups-ver, dv=channels-ver, cv=chat-ver, a=activity |% -+$ init-9 ++$ init-10 $: groups=groups-ui:v11:gv =foreigns:v8:gv channel=channel-10 diff --git a/packages/api/src/__tests__/initApi.test.ts b/packages/api/src/__tests__/initApi.test.ts index 56b99c1813..3cd0761365 100644 --- a/packages/api/src/__tests__/initApi.test.ts +++ b/packages/api/src/__tests__/initApi.test.ts @@ -4,9 +4,9 @@ import { toInitData } from '../client/initApi'; import type * as ub from '../urbit'; import rawGroupsInit6 from './fixtures/groupsInit5.json'; -const groupsInit6 = rawGroupsInit6 as unknown as ub.GroupsInit9; +const groupsInit6 = rawGroupsInit6 as unknown as ub.GroupsInit10; const currentUserId = '~solfer-magfed'; -const toTestInitData = (response: ub.GroupsInit9) => +const toTestInitData = (response: ub.GroupsInit10) => toInitData(response, { currentUserId }); describe('toInitData', () => { diff --git a/packages/api/src/client/changesApi.ts b/packages/api/src/client/changesApi.ts index 2c7ebf2e87..567294abb2 100644 --- a/packages/api/src/client/changesApi.ts +++ b/packages/api/src/client/changesApi.ts @@ -16,9 +16,9 @@ export async function fetchChangesSince(timestamp: number): Promise< > { const busyResult = await checkIsNodeBusyWithHints(); const encodedTimestamp = render('da', da.fromUnix(timestamp)); - const response = await scry({ + const response = await scry({ app: 'groups-ui', - path: `/v10/changes/${encodedTimestamp}`, + path: `/v11/changes/${encodedTimestamp}`, }); const nodeBusyStatus = await Promise.race([busyResult, timedOutDefault(500)]); @@ -28,7 +28,7 @@ export async function fetchChangesSince(timestamp: number): Promise< return { ...changes, ...nodeBusyStatus }; } -export function parseChanges(input: ub.ChangesV10): db.ChangesResult { +export function parseChanges(input: ub.ChangesV11): db.ChangesResult { const groups = toClientGroups(input.groups, true); const channelPosts = Object.entries(input.channels).flatMap( diff --git a/packages/api/src/client/initApi.ts b/packages/api/src/client/initApi.ts index ade67ee6d4..e998c0950e 100644 --- a/packages/api/src/client/initApi.ts +++ b/packages/api/src/client/initApi.ts @@ -32,9 +32,9 @@ type InitDataOptions = { }; export const getInitData = async () => { - const response = await scry({ + const response = await scry({ app: 'groups-ui', - path: '/v9/init', + path: '/v10/init', }); logger.crumb('got init data from api'); @@ -71,7 +71,7 @@ function extractJoinedGroupChannelsFromV7Groups( } export const toInitData = ( - response: ub.GroupsInit9, + response: ub.GroupsInit10, options: InitDataOptions ): InitData => { logger.crumb('converting init data to client data'); diff --git a/packages/api/src/urbit/ui.ts b/packages/api/src/urbit/ui.ts index cff40adcf8..a721b3bd43 100644 --- a/packages/api/src/urbit/ui.ts +++ b/packages/api/src/urbit/ui.ts @@ -4,7 +4,7 @@ import { ContactBookEntry } from './contact'; import { ChatHeadsResponse, DMInit2, Writs } from './dms'; import { Foreigns, GroupV11, Groups, GroupsV11 } from './groups'; -export interface GroupsInit9 { +export interface GroupsInit10 { groups: Record; foreigns: Foreigns; channel: { @@ -29,7 +29,7 @@ export interface Changes { activity: Activity; } -export interface ChangesV10 { +export interface ChangesV11 { groups: GroupsV11; channels: Record; chat: Record; diff --git a/packages/shared/src/store/sync/sync.test.ts b/packages/shared/src/store/sync/sync.test.ts index deee6508ef..4a78509289 100644 --- a/packages/shared/src/store/sync/sync.test.ts +++ b/packages/shared/src/store/sync/sync.test.ts @@ -10,7 +10,7 @@ import { } from '@tloncorp/api'; import { CombinedHeads, - GroupsInit9, + GroupsInit10, PagedPosts, PostDataResponse, } from '@tloncorp/api/urbit'; @@ -61,8 +61,8 @@ const contactsData = rawContactsData as unknown as Record; const contactBookData = rawContactsData2 as unknown as ContactBookScryResult1; const suggestionsData = rawContactSuggestionsData as unknown as string[]; const groupsData = rawGroupsData as unknown as Record; -const groupsInitData = rawGroupsInitData as unknown as GroupsInit9; -const groupsInitData2 = rawGroupsInit2 as unknown as GroupsInit9; +const groupsInitData = rawGroupsInitData as unknown as GroupsInit10; +const groupsInitData2 = rawGroupsInit2 as unknown as GroupsInit10; const headsData = rawHeadsData as unknown as CombinedHeads; setupDatabaseTestSuite(); From 2bed30e43ca5910a9af702a19226424d1dcf41f9 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Wed, 12 Aug 2026 14:48:29 -0500 Subject: [PATCH 24/25] groups: encode init-10 activity with the v10 encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init-10:u carries activity:v10:av from /v6/activity, but the mark still serialized it with activity:v8:enjs. v10 adds %notebook and %note to $source, so v10 does not nest in v8 — the mark could not compile, and had it compiled it would have dropped note unreads from the init JSON. The flip was made while resolving the develop merge but never staged: the file was not among the conflicted paths, so `git add` of the conflict list missed it and the merge committed the index. The Hoon suite passed because run-tests.sh rsyncs the working tree rather than HEAD. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GGt4ADrmVsoD8mxunqeZxp --- desk/mar/ui/init-10.hoon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/mar/ui/init-10.hoon b/desk/mar/ui/init-10.hoon index 21ad3dea7b..2c3fcf4f44 100644 --- a/desk/mar/ui/init-10.hoon +++ b/desk/mar/ui/init-10.hoon @@ -11,7 +11,7 @@ %- pairs :~ groups/(groups-ui:v11:enjs:gj groups.init) foreigns/(foreigns:v8:enjs:gj foreigns.init) - activity/(activity:v8:enjs:aj activity.init |) + activity/(activity:v10:enjs:aj activity.init |) pins/a/(turn pins.init whom:enjs:gj) profile/b/profile.init :: From f778ccdfaef8f59abbdc2b69d651ed9be0c23937 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Fri, 14 Aug 2026 15:02:56 -0500 Subject: [PATCH 25/25] bot-e2e: apply the branch desk to ~mug too The harness boots ~zod, ~ten and ~mug (docker-compose.base.yml serves mug on 8082 from rube-mug29.tgz) but only committed the branch desk onto the first two, leaving ~mug on whatever desk its pier archive shipped with. A mixed-version fleet is harmless until a branch bumps the negotiated %groups protocol version. This one does (~.groups %2 -> %3, since u-group %create embeds $group), so ~zod and ~ten classify ~mug as a certain-mismatch, and negotiate does not defer a poke to a mismatching gill -- lib/negotiate.hoon:295-302 crashes outright: ~| [%negotiate %poke-to-mismatching-gill gill] !! ~mug is the reel/lure provider, so every scenario touching group or channel setup died while DM-only scenarios passed: 10 failed, 12 passed. The failure was near-undiagnosable because the crash arrives through a spider thread whose outputMark is json, and mar/tang.hoon is not in peru.yaml's pick list -- so %tang -> %json has no cast and the tang never reaches the caller. Only the ship console carried the hint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GGt4ADrmVsoD8mxunqeZxp --- packages/tlon-bot-e2e/README.md | 2 +- packages/tlon-bot-e2e/src/runtime/branch-desk.test.ts | 4 ++-- packages/tlon-bot-e2e/src/runtime/branch-desk.ts | 7 ++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/tlon-bot-e2e/README.md b/packages/tlon-bot-e2e/README.md index c3bf1fa9dd..1ee234e2dc 100644 --- a/packages/tlon-bot-e2e/README.md +++ b/packages/tlon-bot-e2e/README.md @@ -12,7 +12,7 @@ Use `packages/tlon-bot-e2e/.env.example` as the template for local values such a Runner controls such as `TLON_BOT_E2E_SUITE` and `TLON_BOT_E2E_SCENARIO_PARTITIONS` are also allowed in this package `.env` for local workflows. Shell values still win over the file, so commands can override them for one-off runs without editing local config. -After the fake ships become ready, the runner assembles and applies the checkout's `%groups` desk. `TLON_BOT_E2E_DESK_SHIPS` selects a comma-separated ship set and defaults to `~zod,~ten`. This requires `peru` and `rsync`, as described in the root desk-dependency documentation. +After the fake ships become ready, the runner assembles and applies the checkout's `%groups` desk. `TLON_BOT_E2E_DESK_SHIPS` selects a comma-separated ship set and defaults to `~zod,~ten,~mug` — every ship the harness boots. Narrowing it leaves the omitted ships on their pier's desk, which breaks any branch that bumps a negotiated protocol version. This requires `peru` and `rsync`, as described in the root desk-dependency documentation. The loader only accepts the explicit harness allowlist in `src/runtime/env.ts`. Unknown keys fail the run instead of being passed through implicitly. Docker Compose is still launched with `.env` auto-loading disabled, so package-local files such as `packages/openclaw/.env` and `packages/hermes-tlon-adapter/.env` do not bleed into shared E2E containers. diff --git a/packages/tlon-bot-e2e/src/runtime/branch-desk.test.ts b/packages/tlon-bot-e2e/src/runtime/branch-desk.test.ts index f984cd0b4f..dbac90d7af 100644 --- a/packages/tlon-bot-e2e/src/runtime/branch-desk.test.ts +++ b/packages/tlon-bot-e2e/src/runtime/branch-desk.test.ts @@ -31,8 +31,8 @@ afterEach(async () => { }); describe('branch desk ship set', () => { - test('defaults to zod and ten and normalizes configured ships', () => { - expect(parseDeskShips()).toEqual(['zod', 'ten']); + test('defaults to every running ship and normalizes configured ships', () => { + expect(parseDeskShips()).toEqual(['zod', 'ten', 'mug']); expect(parseDeskShips(' zod, ~mug,~zod ')).toEqual(['zod', 'mug']); }); diff --git a/packages/tlon-bot-e2e/src/runtime/branch-desk.ts b/packages/tlon-bot-e2e/src/runtime/branch-desk.ts index 31a5eecb28..e2bca88337 100644 --- a/packages/tlon-bot-e2e/src/runtime/branch-desk.ts +++ b/packages/tlon-bot-e2e/src/runtime/branch-desk.ts @@ -14,7 +14,12 @@ import { waitFor, waitForShipLogin } from './waiters.js'; type ShipLabel = keyof RuntimeContext['endpoints']['ships']; -const DEFAULT_DESK_SHIPS = '~zod,~ten'; +// Every running ship, not just the two the scenarios drive directly. ~mug is +// the reel/lure provider, so leaving it on its pier's desk makes a mixed-version +// fleet — harmless until a change bumps the negotiated %groups protocol version, +// at which point ~zod/~ten treat ~mug as a certain-mismatch and negotiate's +// poke path crashes outright (`%poke-to-mismatching-gill`) rather than deferring. +const DEFAULT_DESK_SHIPS = '~zod,~ten,~mug'; const STAGED_DESK = '/tmp/tlon-bot-e2e-groups'; const COMMIT_ATTEMPTS = 4; const ASSEMBLE_DESK_TIMEOUT_MS = 300_000;