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/53-list-bots-grant-scope.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**list_bots stays inside the grant.** It lists your owner's Bots, Bots granted to that owner, and Bots in the current room — not every other owner's Bot by name. A granted child only sees its own subtree, not the owner's whole roster with folders and instructions. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53) follow-up)
1 change: 1 addition & 0 deletions changelog.d/54-task-lifecycle.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Tracked tasks.** A request can be created as a task with a stable id, acceptance criteria, and a visible state — claimed, worked, blocked while a send waits for your approval, returned, and accepted by you (or cancelled). You do not have to own the Bot: a grant to you, or sharing a room with it, is enough to hand work to a granted child. Wrong-account moves are refused, a task cannot be accepted before its result exists, and deleting the responsible Bot fails its tasks instead of dropping them. Task views in the chat are next ([#57](https://github.com/ThinkFlowLab/FlyBot/issues/57)). ([#54](https://github.com/ThinkFlowLab/FlyBot/issues/54))
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as memberAuth from './src/host/member-auth.js'
import * as authz from './src/host/authz.js'
import * as roomMembership from './src/host/room-membership.js'
import * as reach from './src/host/reach.js'
import * as tasks from './src/host/tasks.js'
import * as models from './src/host/models.js'
import * as permissions from './src/host/permissions.js'
import * as turns from './src/host/turns.js'
Expand Down Expand Up @@ -46,7 +47,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel]
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, tasks, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
4 changes: 4 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,10 @@ export function install(rt) {
// Sharing a member Bot's brief is the Bot owner's decision, checked on the record
// in room-membership (#53); the endpoint itself only needs a signed-in actor.
if (ep === 'share-brief') return ALLOW
// Task transitions are requester-checked on the record in tasks.js (#54) — the
// endpoint only needs a signed-in actor, like held-send decisions. Create is
// grant-aware there (owner, matching grant, or the named room).
if (ep === 'tasks' || ep.startsWith('task-')) return ALLOW

// Anything not in the table and not a known auth/read endpoint is denied.
if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW
Expand Down
9 changes: 9 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ export function install(rt) {
case 'diagnostics-clear':
await rt.diagnostics.clear()
return ok(true)
case 'tasks':

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow task listings on read-only hosts

When a Web or Desktop instance is following another DSH process in read-only mode, handle() rejects every endpoint absent from READS at core/src/host/channel.js:151. The new tasks endpoint only reads the refreshed state but was not added to that set, so listing tasks returns bot/read-only even though other query endpoints remain available; add tasks to the read-only allowlist while keeping the mutation endpoints blocked.

Useful? React with 👍 / 👎.

case 'task-create':
case 'task-accept':
case 'task-start':
case 'task-return':
case 'task-accept-result':
case 'task-cancel':
await rt.load()
return ok(await rt.taskEndpoint(endpoint, body, actor))
case 'share-brief':
await rt.load()
await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor)
Expand Down
6 changes: 6 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ export function install(rt) {
fire('bot.removed', { ...last, botId: id, bot: last })
// A hold naming this Bot as sender or target can no longer be spent (#52).
await invalidateFor([id], 'the Bot was deleted')
// Its open tasks fail rather than wait forever (#54).
await rt.taskBotDeleted?.(id)
// Deleting a friend's Bot's chat clears it; the friend's Bot is still there, so it gets a
// fresh, empty chat at once. Otherwise its row waits ("opening its chat…") until the
// plugin next publishes its roster, which may be hours or the next restart.
Expand Down Expand Up @@ -378,6 +380,8 @@ export function install(rt) {
for (const old of done.slice(0, Math.max(0, Object.keys(all).length - APPROVALS_KEPT))) delete all[old.id]
await rt.save()
approvalsChanged(approval)
// A working task's consequential send is held: the task blocks on this card (#54).
try { await rt.taskHeld?.(msg, approval) } catch (error) { warn('flybot: task hold failed: %s', error) }
return approval
}

Expand Down Expand Up @@ -462,6 +466,8 @@ export function install(rt) {
approval.decidedAt = Date.now()
if (note) approval.note = String(note)
if (decision === 'approve') await release(approval)
// The blocked task resumes on approve, or ends on reject (#54).
try { await rt.taskDecided?.(approval, decision) } catch (error) { warn('flybot: task decision failed: %s', error) }
await rt.save()
approvalsChanged(approval)
return clone(approval)
Expand Down
7 changes: 6 additions & 1 deletion core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1158,7 +1158,12 @@ export function install(rt) {
if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name)
if (start === -1) start = log.length
const line = (entry) => {
const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot'
// A grantee's human lines are a peer's, never another Bot's (#53 follow-up).
const who = isUserLine(bot, entry)
? 'user'
: (entry.botId === bot.id || entry.authorId === bot.id)
? 'you'
: (entry.authorKind === 'human' ? 'peer' : 'bot')
return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq }
}
return {
Expand Down
8 changes: 6 additions & 2 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array.
export function install(rt) {
const { ctx, home, statePath } = rt

/** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record<string,any>, bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number, invitations:Record<string,any>, authSessions:Record<string,any>}} */
const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, revision: 0 }
/** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record<string,any>, bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number, invitations:Record<string,any>, authSessions:Record<string,any>, tasks:Record<string,any>}} */
const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, revision: 0 }
let loaded = null
let writing = Promise.resolve()
const { warn } = rt
Expand Down Expand Up @@ -305,6 +305,10 @@ export function install(rt) {
state.authSessions = {}
migrated = true
}
if (!isRecord(state.tasks)) {
state.tasks = {}
migrated = true
}
if (ensureOwner()) migrated = true
if (legacy) {
const ownerId = Object.keys(state.members)[0]
Expand Down
194 changes: 194 additions & 0 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Tasks (#54): a tracked request with a stable id and a small state machine, so
// responsibilities and completion are visible beyond chat text. Team mode stays
// disabled: this is the task-record foundation the later team gates build on
// (docs/subsystems/team.md) — in the single-human product every transition is the
// requester's; with member sessions only the requester's own may move their task,
// and the record carries server-derived authority, never body claims (#52 rule).
//
// Create is grant-aware: the requester need not own the Bot. A granted child (or a
// Bot in the named room the requester currently sits in) may take a task within
// its authority. Outgoing holds stay the owner's (#52 card).
//
// States: open → claimed → working → blocked ⇄ working → result-ready → accepted;
// cancelled and failed are terminal from any active state, and accepted is reachable
// only from result-ready — so a failed or cancelled task can never display as
// accepted. The record stores pointers and a summary, never private transcripts.
import { randomBytes } from 'node:crypto'
import { currentParticipants } from './room-membership.js'

const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked'])
const TRANSITIONS = {
open: ['claimed', 'cancelled', 'failed'],
claimed: ['working', 'cancelled', 'failed'],
working: ['blocked', 'result-ready', 'cancelled', 'failed'],
blocked: ['working', 'cancelled', 'failed'],
'result-ready': ['accepted'],
accepted: [],
cancelled: [],
failed: [],
}
const KEPT = 200

const stale = message => Object.assign(new Error(message), { code: 'bot/stale' })
const forbidden = message => Object.assign(new Error(message), { code: 'bot/forbidden' })

export function install(rt) {
const { state } = rt
state.tasks ??= {}

const store = () => {
state.tasks ??= {}
return state.tasks
}
// The requester is the human whose task this is: the signed-in actor, or the one
// owner of a host nobody has signed in on. Derived, never a body claim.
const requesterOf = actor => actor?.memberId ?? Object.keys(state.members ?? {})[0] ?? null
const mayAct = (task, actor) => {
if (!rt.authzEnforced?.()) return true
return actor !== null && actor !== undefined && task.requesterId === actor.memberId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear tasks before removing their requester

When a grantee or room participant with no owned Bots creates a task and is later removed, core/src/host/member-auth.js:162-173 allows the removal without touching state.tasks; this requester check then makes the task inaccessible, and the next invitation can reuse the freed owner-N ID, letting a different person see and transition the former member's task. Fail or remove that member's tasks, or reject member removal while such tasks exist.

Useful? React with 👍 / 👎.

}
const find = id => store()[String(id ?? '')]
// Only the record's own requesters may move their tasks; the responsible Bot is
// checked at create time (owner, grant, or shared-room membership).
const requireRequester = (task, actor) => {
if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task')
}

// Owner, a matching grant, or a current human in the named room with that Bot.
const mayCreateOn = (bot, actor, roomId) => {
if (!rt.authzEnforced?.()) return true
const requesterId = actor?.memberId
if (!requesterId) return false
if (bot.ownerId === requesterId) return true
if (rt.hasGrant?.(bot.id, requesterId, roomId) || rt.hasGrant?.(bot.id, requesterId)) return true
if (!roomId) return false
const room = state.rooms?.[String(roomId)]
if (room === undefined) return false
const live = currentParticipants(room)
return live.some(p => p.kind === 'human' && p.id === requesterId)
&& live.some(p => p.kind === 'bot' && !p.remote && p.id === bot.id)
}

function transition(task, to, actor, extra) {
if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`)
task.status = to
task.updatedAt = Date.now()
task.actors.push({ status: to, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...extra })
}

const view = task => ({ ...task, actors: [...task.actors ?? []] })
const prune = () => {
const all = Object.values(store())
const terminal = all.filter(task => !ACTIVE.has(task.status)).sort((a, b) => a.updatedAt - b.updatedAt)
for (const task of terminal.slice(0, Math.max(0, all.length - KEPT))) delete store()[task.id]
}
const save = () => rt.save()

// ---------------------------------------------------------------- host hooks
// A consequential send from the responsible Bot was held (#52 card): the task
// blocks on that approval until the owner decides.
rt.taskHeld = (msg, approval) => {
for (const task of Object.values(store())) {
if (task.status !== 'working' || task.ownerBotId !== msg.from) continue
task.approvalId = approval.id
transition(task, 'blocked', null, { approvalId: approval.id })
}
return save()
}
// The owner's decision on that approval resumes the task — or, on reject, ends it.
rt.taskDecided = (approval, decision) => {
for (const task of Object.values(store())) {
if (task.status !== 'blocked' || task.approvalId !== approval.id) continue
delete task.approvalId
transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' })
}
return save()
}
// The responsible Bot is gone: its open tasks fail (they are never silently dropped).
rt.taskBotDeleted = botId => {
for (const task of Object.values(store())) {
if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue
transition(task, 'failed', null, { why: 'the responsible Bot was deleted' })
}
return save()
}

// ---------------------------------------------------------------- channel API
rt.taskEndpoint = async function taskEndpoint(endpoint, body, actor) {
switch (endpoint) {
case 'tasks':
return Object.values(store())
.filter(task => mayAct(task, actor))
.sort((a, b) => a.createdAt - b.createdAt)
.map(view)
case 'task-create': {
const botId = String(body.botId ?? '')
const bot = state.bots?.[botId]
if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot')
const roomId = body.roomId ? String(body.roomId) : undefined
if (!mayCreateOn(bot, actor, roomId)) throw forbidden('You cannot assign a task to that Bot')
const requesterId = requesterOf(actor)
const task = {
id: `task-${randomBytes(8).toString('hex')}`,
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
requesterId,
ownerBotId: botId,
title: String(body.title ?? '').slice(0, 300),
...(typeof body.criteria === 'string' && body.criteria.trim() !== '' ? { criteria: body.criteria.slice(0, 2000) } : {}),
origin: { ...(roomId ? { roomId } : {}), ...(body.sessionId ? { sessionId: String(body.sessionId) } : {}) },
actors: [{ status: 'open', at: Date.now(), ...(actor?.memberId ? { memberId: actor.memberId } : {}) }],
}
store()[task.id] = task
prune()
await save()
return view(task)
}
case 'task-accept': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'claimed', actor)
await save()
return view(task)
}
case 'task-start': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'working', actor)
await save()
return view(task)
}
case 'task-return': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'result-ready', actor)
task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000) }
await save()
return view(task)
}
case 'task-accept-result': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'accepted', actor)
await save()
return view(task)
}
case 'task-cancel': {
const task = find(body.id)
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
requireRequester(task, actor)
transition(task, 'cancelled', actor)
await save()
return view(task)
}
default:
throw Object.assign(new Error(`Unknown task endpoint ${endpoint}`), { code: 'bot/not-found' })
}
}
return rt
}
Loading
Loading