Repository navigation
feat(tasks): track handoffs and requester acceptance (#54) #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| 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)) |
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a grantee or room participant with no owned Bots creates a task and is later removed, 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a Web or Desktop instance is following another DSH process in read-only mode,
handle()rejects every endpoint absent fromREADSatcore/src/host/channel.js:151. The newtasksendpoint only reads the refreshed state but was not added to that set, so listing tasks returnsbot/read-onlyeven though other query endpoints remain available; addtasksto the read-only allowlist while keeping the mutation endpoints blocked.Useful? React with 👍 / 👎.