Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1764-room-rename-face-to-face.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Renaming a face-to-face room no longer fails with "A group chat needs at least two Bots": the host applies that floor only to a patch that actually changes membership, so a name, notice or reply-mode edit goes through in a face-to-face or inbound-share room while a normal room's membership patch still needs two Bots ([#1764](https://github.com/ThinkFlowLab/FlyBot/issues/1764)).
2 changes: 1 addition & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13581,7 +13581,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
(0, import_react29.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react29.createElement)(
"span",
{ className: "bt-set-hint" },
// The host checks for two members on every change, even a rename.
// The host's two-Bot floor guards membership changes; this hint warns before one.
members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
)),
// #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
? h('button', { type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(true) }, h(PlusIcon), t('Add member'))
: null,
h('div', { className: 'bt-set-row bt-gs-note' }, h('span', { className: 'bt-set-hint' },
// The host checks for two members on every change, even a rename.
// The host's two-Bot floor guards membership changes; this hint warns before one.
members.length + humans.length + remotes.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
: `${admin ? t('{name} leads this group: it can call members with @Name, change the group, and post here.', { name: admin.name })
: t('No admin yet. An admin can call members with @Name, change the group, and post here.')}${room.origin === 'face-to-face' || room.origin === 'inbound-share' ? '' : ` ${t('The task lead divides the board. When unset, the admin does.')}`}`)),
Expand Down
12 changes: 9 additions & 3 deletions core/src/host/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ export function install(rt) {
}
}

function checkMembers(members) {
if (members.length < 2) throw new Error('A group chat needs at least two Bots')
function checkMembers(members, { floor = true } = {}) {
if (floor && members.length < 2) throw new Error('A group chat needs at least two Bots')
if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`)
}

Expand Down Expand Up @@ -375,7 +375,13 @@ export function install(rt) {
let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members]
for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member)
members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member))
checkMembers(members)
// #1764: only a patch that changes membership is judged by the two-Bot floor. A face-to-face
// or inbound-share room's seats are synced, not patched, so its local list is not its membership.
const changesMembers = patch.members !== undefined || patch.add !== undefined || patch.remove !== undefined
if (changesMembers) {
const syncedSeats = room.origin === 'face-to-face' || room.origin === 'inbound-share'
checkMembers(members, { floor: !syncedSeats })
}
let admin = room.admin ?? null
if (patch.admin !== undefined) {
admin = patch.admin || null
Expand Down
20 changes: 20 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2400,3 +2400,23 @@ test('#1167: a hidden shared room stays hidden for a later line or a stale join,
assert.equal(room.hidden, undefined, 'the room opens again')
assert.equal(roomVisibleTo(rt.state, actorB, room.id), true)
})

test('#1764: a face-to-face room is renamed without the two-Bot floor', async (t) => {
const { call, rt } = startChannel(t, TEAM)
await rt.load()
const room = await rt.upsertFaceRoom(FACE_SPEC)
assert.deepEqual(room.members, ['spec-a'], 'only the local Bot is a member; the seats are remote')
const renamed = await call('update-room', { id: room.id, name: 'Renamed room' })
assert.equal(renamed.ok, true, renamed.error?.message)
assert.equal(rt.roomOf(room.id).name, 'Renamed room')
assert.deepEqual(rt.roomOf(room.id).members, ['spec-a'], 'the rename changes no membership')
})

test('#1764: a normal room still refuses a membership patch that leaves one Bot', async (t) => {
const { call, rt } = startChannel(t, TEAM)
const refused = await call('update-room', { id: 'war', members: ['chief-a'] })
assert.equal(refused.ok, false)
assert.match(refused.error.message, /at least two Bots/)
await rt.load()
assert.deepEqual(rt.roomOf('war').members, ['chief-a', 'chief-b'], 'the refused patch changed nothing')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi

- `core/src/host/face-lead.js` holds a face room's lead (总负责人): `cleanFaceLead` (what `upsertFaceRoom` keeps on `room.face.lead`: `{ handle, picked, canPick, supported }`; a spec without `lead` keeps the old one), `faceLeadOf`, `faceRoster` (everyone in the room, from every home, with each Bot's role: mine from the roster, a friend's from Bot Social `members[].role`, kept as `roleLabel` on the remote participant and cleaned like a name), `FACE_LEAD_NOTE` and `faceMemberNote`.
- `upsertFaceRoom` makes the lead the room's `admin` when it is one of this home's Bots (else `admin: null`), so `plainSpeakers` asks it first and `adminNote` gives it the lead's rules (plan once, assign parts by @Name); every other Bot gets `faceMemberNote` (do only your part, answer when named, no re-plan; a plain line is the lead's). `roomPrompt`'s Members line is the full roster in a face room. A lead change reaches the next round on every home through the next poll.
- The face lead's admin role is not a team role: `updateRoom` refuses it member or admin changes in a face room, and `memory.js` `writesTeam` ignores face rooms. `MAIN_RULES` says a Main Bot leads a face room only when it is that room's lead; `TEAM_RULES`' "write a deliverable in full" leaves the plan to the lead in a group that has one.
- The face lead's admin role is not a team role: `updateRoom` refuses it member or admin changes in a face room, and `memory.js` `writesTeam` ignores face rooms. The two-Bot floor runs only for a patch that changes membership, and never in a face-to-face or inbound-share room whose seats are synced (#1764), so a rename goes through while an ordinary room still refuses a member list of one. `MAIN_RULES` says a Main Bot leads a face room only when it is that room's lead; `TEAM_RULES`' "write a deliverable in full" leaves the plan to the lead in a group that has one.
- Routing (`groups.js`): on a line that names no Bot, my lead's `routeNote` carries `FACE_LEAD_ROUTE` (the line is its own; it passes only when the line is plainly another Bot's) in place of "Default to [PASS]", which the other Bots keep. `fallbackDue` forces no answer when the lead is another home's Bot in the room now (`faceLeadIsRemote`): that lead answers what my Bots leave. With no lead known, my own lead, or a lead who left, #475's fallback stays.

### Face-to-face participant look
Expand Down
62 changes: 39 additions & 23 deletions patches/ds-bot/0002-flybot-api-host.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/).

diff --git a/index.js b/index.js
--- a/index.js
+++ b/index.js
Expand Down Expand Up @@ -139,7 +138,7 @@ diff --git a/src/host/home-lock.js b/src/host/home-lock.js
diff --git a/src/host/roster.js b/src/host/roster.js
--- a/src/host/roster.js
+++ b/src/host/roster.js
@@ -5,6 +5,8 @@ import { join } from 'node:path'
@@ -5,6 +5,8 @@
import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
import { currentColor, isColor, unquote } from './text.js'
import { nameProblem } from '../shared/mentions.js'
Expand All @@ -148,7 +147,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js

export function install(rt) {
const { ctx, config, home, defaults, state } = rt
@@ -15,7 +15,7 @@ export function install(rt) {
@@ -15,7 +15,7 @@
if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) return state.workspaceId
const path = config.workspace ?? join(home, 'workspace')
await mkdir(path, { recursive: true })
Expand All @@ -157,7 +156,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
state.workspaceId = workspace.id
await rt.save()
return workspace.id
@@ -56,18 +56,49 @@ export function install(rt) {
@@ -56,18 +56,49 @@
return `${stem} ${index}`
}

Expand Down Expand Up @@ -215,7 +214,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
const id = await createSession(cleanName, { dir })
try {
state.bots[id] = {
@@ -80,45 +80,48 @@ export function install(rt) {
@@ -80,45 +80,48 @@
...(modelRef ? { model: modelRef } : {}),
...(effortId ? { effort: effortId } : {}),
...(dir ? { workDir: dir } : {}),
Expand Down Expand Up @@ -288,7 +287,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
// A save that another change queued meanwhile still carries this Bot; a later
// one writes the team without it.
void rt.save().catch(() => {})
@@ -128,24 +128,12 @@ export function install(rt) {
@@ -128,24 +128,12 @@
}
}

Expand All @@ -315,15 +314,15 @@ diff --git a/src/host/roster.js b/src/host/roster.js
name: freshName(`${bot.name} copy`),
role: bot.role,
instructions: bot.instructions,
@@ -155,7 +155,6 @@ export function install(rt) {
@@ -155,7 +155,6 @@
effort: served ? bot.effort : undefined,
// A folder that went away since leaves the copy in the team workspace.
workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined,
- copyOf: bot.id,
})
}

@@ -189,6 +189,8 @@ export function install(rt) {
@@ -189,6 +189,8 @@
if (avatar) bot.avatar = avatar
else delete bot.avatar
}
Expand All @@ -332,7 +331,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
if (patch.model !== undefined) {
if (patch.model === null || patch.model === '') delete bot.model
else bot.model = await rt.resolveModel(patch.model)
@@ -212,17 +212,11 @@ export function install(rt) {
@@ -212,17 +212,11 @@
await rt.save()
if (modelChanged) await rt.applyModel(bot, { force: true })
if (moved) void rt.followFolder(bot.id)
Expand All @@ -351,7 +350,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
if (entry === undefined) throw new Error('Unknown conversation')
if (typeof pinned === 'boolean') entry.pinned = pinned
if (typeof hidden === 'boolean') entry.hidden = hidden
@@ -235,7 +235,14 @@ export function install(rt) {
@@ -235,7 +235,14 @@
// admin may change its own group but not delete it. Only a member can be admin.

const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id)
Expand All @@ -367,7 +366,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
const cleanNotice = text => String(text ?? '').trim().slice(0, ROOM_NOTICE_MAX)

function cleanRoomName(text, selfId) {
@@ -257,7 +257,7 @@ export function install(rt) {
@@ -257,21 +257,36 @@
async function followMemberNames(rooms = Object.values(state.rooms)) {
for (const room of rooms) {
if (room.named) continue
Expand All @@ -376,7 +375,13 @@ diff --git a/src/host/roster.js b/src/host/roster.js
if (next === room.name) continue
room.name = next
await retitleRoom(room)
@@ -269,9 +269,24 @@ export function install(rt) {
}
}

- function checkMembers(members) {
- if (members.length < 2) throw new Error('A group chat needs at least two Bots')
+ function checkMembers(members, { floor = true } = {}) {
+ if (floor && members.length < 2) throw new Error('A group chat needs at least two Bots')
if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`)
}

Expand Down Expand Up @@ -404,7 +409,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
const custom = cleanRoomName(roomName ?? '')
// A Main Bot leads the groups it makes; a group the user makes is led by a Main
// Bot member when it has one.
@@ -294,7 +294,19 @@ export function install(rt) {
@@ -294,7 +294,19 @@
createdAt: Date.now(),
createdBy: createdBy ?? null,
}
Expand All @@ -424,7 +429,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
return state.rooms[id]
} catch (error) {
// The group's Session was already made: archive it instead of leaving an orphan.
@@ -306,13 +306,19 @@ export function install(rt) {
@@ -306,17 +306,29 @@
}
}

Expand All @@ -447,7 +452,18 @@ diff --git a/src/host/roster.js b/src/host/roster.js
let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members]
for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member)
members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member))
@@ -328,6 +328,14 @@ export function install(rt) {
- checkMembers(members)
+ // #1764: only a patch that changes membership is judged by the two-Bot floor. A face-to-face
+ // or inbound-share room's seats are synced, not patched, so its local list is not its membership.
+ const changesMembers = patch.members !== undefined || patch.add !== undefined || patch.remove !== undefined
+ if (changesMembers) {
+ const syncedSeats = room.origin === 'face-to-face' || room.origin === 'inbound-share'
+ checkMembers(members, { floor: !syncedSeats })
+ }
let admin = room.admin ?? null
if (patch.admin !== undefined) {
admin = patch.admin || null
@@ -328,6 +328,14 @@
admin = null
}
if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
Expand All @@ -462,7 +478,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
const moving = patch.workDir !== undefined
@@ -340,14 +340,24 @@ export function install(rt) {
@@ -340,14 +340,24 @@
const removed = room.members.filter(member => !members.includes(member))
if (added.length > 0) changes.push(`added ${names(added)}`)
if (removed.length > 0) changes.push(`removed ${names(removed)}`)
Expand Down Expand Up @@ -492,7 +508,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
if (patch.mode !== undefined && patch.mode !== room.mode) {
room.mode = patch.mode
changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn')
@@ -371,7 +371,13 @@ export function install(rt) {
@@ -371,7 +371,13 @@
delete room.named
}
}
Expand All @@ -507,7 +523,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
if (room.name !== before) {
changes.push(`renamed it "${room.name}"`)
await retitleRoom(room)
@@ -383,8 +383,11 @@ export function install(rt) {
@@ -383,8 +383,11 @@
async function deleteRoom(id) {
const room = rt.roomOf(id)
if (room === undefined) throw new Error('Unknown group chat')
Expand All @@ -519,7 +535,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
rt.roomInbox.delete(id)
const failed = await saveForDelete()
// Armed scheduled tasks refuse a plain archive and would keep delivering into
@@ -409,6 +409,10 @@ export function install(rt) {
@@ -409,6 +409,10 @@
} else if (replace !== undefined && replace !== id) {
const at = others.indexOf(replace)
if (at === -1) throw new Error('Only a Main Bot can be replaced')
Expand All @@ -530,7 +546,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
next = others.with(at, id)
} else if (primary) {
next = [id, ...others]
@@ -417,14 +417,38 @@ export function install(rt) {
@@ -417,14 +417,38 @@
}
// The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted.
if (main) delete bot.hidden
Expand Down Expand Up @@ -573,7 +589,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
async function deleteBot(id) {
const bot = rt.botOf(id)
if (bot === undefined) throw new Error('Unknown Bot')
@@ -434,22 +434,42 @@ export function install(rt) {
@@ -434,22 +434,42 @@
const current = rt.chatOf(id)
// Questions and exchange turns are kept per Session, so every part of the chat is cleared.
const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
Expand Down Expand Up @@ -616,7 +632,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
const failed = await saveForDelete()
// Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
// Past parts are archived too: a part move can leave tasks on the old Session,
@@ -481,7 +481,9 @@ export function install(rt) {
@@ -481,7 +481,9 @@
if (state.mainBotIds.some(id => rt.botOf(id))) return
// Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a
// look from their id, and a Bot made Main Bot later keeps its own.
Expand All @@ -627,7 +643,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js
.finally(() => { bootstrapping = null })
await bootstrapping
}
@@ -489,6 +489,6 @@ export function install(rt) {
@@ -489,6 +489,6 @@
Object.assign(rt, {
ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags,
roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom,
Expand Down
2 changes: 1 addition & 1 deletion patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -12398,7 +12398,7 @@ diff --git a/client.js b/client.js
+ (0, import_react29.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react29.createElement)(
+ "span",
+ { className: "bt-set-hint" },
+ // The host checks for two members on every change, even a rename.
+ // The host's two-Bot floor guards membership changes; this hint warns before one.
+ members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
+ )),
+ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
Expand Down
Loading
Loading