From 2b76eb55698954a65d3290c615eb00808c6287b8 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 1 Nov 2026 12:11:43 +0000 Subject: [PATCH] Say how long a blocked card has been stuck, and sort the Blocked group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blocked group answered why a card was stuck and never how long, while the Waiting group beside it showed a duration and read oldest-first. A card blocked for three days and one blocked for three minutes rendered identically, and the card that had been stuck longest was not the one on top. `blockedOf` now reads the `inputRequest.requestedAt` stamp the ask itself wrote and answers the same age shape `waitingOf` does: `since` (or `null` when the ask wrote none) and `waitedMs` (`now - since`, or `null`). It never falls back to `createdAt`/`updatedAt`: an invented stamp would read as a wait nobody measured. `buildQueues` sorts `blocked` with the comparator `waiting.sort` already uses, so an undated card sorts last and the Waiting output is unchanged. The client draws `reason · 2d` when the host measured a wait and the reason alone when it did not. --- changelog.d/2029-blocked-card-age.changed.md | 1 + core/client.js | 4 +- core/src/client/project-board.js | 8 +++- core/src/host/project-board.js | 23 +++++++--- core/test/project-board-view.test.js | 18 ++++++++ core/test/project-board.test.js | 44 +++++++++++++++++++ docs/subsystems/core-client.md | 4 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 4 +- .../ds-bot/files/src/client/project-board.js | 8 +++- .../ds-bot/files/src/host/project-board.js | 23 +++++++--- .../files/test/project-board-view.test.js | 18 ++++++++ .../ds-bot/files/test/project-board.test.js | 44 +++++++++++++++++++ 13 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 changelog.d/2029-blocked-card-age.changed.md diff --git a/changelog.d/2029-blocked-card-age.changed.md b/changelog.d/2029-blocked-card-age.changed.md new file mode 100644 index 000000000..010f48189 --- /dev/null +++ b/changelog.d/2029-blocked-card-age.changed.md @@ -0,0 +1 @@ +A Project board's Blocked group now says how long a card has been stuck, and lists the card stuck longest first ([#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029)): the row kept the card's own reason and read the same whether the card had been blocked for three days or three minutes, and the group was unsorted. The host now answers `since` / `waitedMs` from the question's own `requestedAt` stamp — a card whose ask wrote no stamp shows no duration rather than an invented one — and the board draws `reason · 2d`, ordering the group oldest first the way Waiting already is. diff --git a/core/client.js b/core/client.js index 22833ea05..e91c09a5f 100644 --- a/core/client.js +++ b/core/client.js @@ -10654,7 +10654,7 @@ function projectBoardView({ project, rows, done, canManage, tasks, decisions, me }); const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); - const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null })); + const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); const queueGroups = [ { group: "waiting", label: "Waiting", items: waiting }, { group: "inflight", label: "In flight", items: inflight }, @@ -10849,7 +10849,7 @@ function ProjectQueueGroups({ view, onOpen }) { (0, import_react19.createElement)("h4", null, t(entry.label)), entry.items.map((row) => { const open = row.board === null ? void 0 : () => onOpen?.(row.board); - const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason); + const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); return (0, import_react19.createElement)( "div", { diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 594b17389..996137811 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -311,7 +311,9 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi }) const waiting = queueItems(queues?.waiting, item => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) const inflight = queueItems(queues?.inflight, item => ({ holder: text(item.holder) || null, state: item.stalled === true ? 'stalled' : 'running' })) - const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null })) + // #2029: a blocked row carries the age the host measured beside its reason, the same way the + // waiting row does — the view never measures it here. + const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) const queueGroups = [ { group: 'waiting', label: 'Waiting', items: waiting }, { group: 'inflight', label: 'In flight', items: inflight }, @@ -503,7 +505,9 @@ function ProjectQueueGroups({ view, onOpen }) { ? h('span', { className: 'bt-project-wait' }, [row.role === null ? null : h('span', { key: 'role', className: 'bt-project-role' }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === 'inflight' ? h('span', { className: 'bt-project-state' }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) - : h('span', { className: 'bt-project-reason' }, row.reason) + // #2029: a blocked row says why and, when the host measured it, how long — `reason · 2d`. + // With no stamp it keeps the reason alone; an invented age would read as a wait nobody measured. + : h('span', { className: 'bt-project-reason' }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`) return h('div', { key: row.key, className: 'bt-project-row', diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index 7d9bf07aa..f875c541e 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -131,13 +131,22 @@ export function inflightOf(task) { return { id: String(task.id), holder } } -// Blocked: the reason is the card's own line, never a second copy. -export function blockedOf(task) { +// Blocked: the reason is the card's own line, never a second copy. #2029 adds the age the ask +// already stamped (`inputRequest.requestedAt`, `tasks.js`) — a card blocked for three days and one +// blocked for three minutes must not read alike. A card with no stamp has no age: the card's own +// `createdAt`/`updatedAt` are not the question's clock, and an invented wait is worse than none. +export function blockedOf(task, now) { if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || '' - return { id: String(task.id), reason } + const since = stampOf(task.inputRequest?.requestedAt) + return { id: String(task.id), reason, since, waitedMs: since === null ? null : Math.max(0, now - since) } } +// Oldest wait first: the card that has waited longest is the one a person wants next. Shared by the +// Waiting and the Blocked groups so the two read in the same order; a card the host cannot date +// sorts last rather than first. +const oldestFirst = (a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id) + // A decision is read for its title line only: the head of the file is enough for a heading, and // the body stays where it is. The bound also keeps a megabyte file from being read to answer one // line; the hash the reader needs comes from `listProjectState`, not from this read. @@ -504,11 +513,13 @@ export function install(rt) { const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live }) } - const block = blockedOf(task) + const block = blockedOf(task, now) if (block !== null) blocked.push(block) } - // Oldest wait first: the card that has waited longest is the one a person wants next. - waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id)) + // Oldest wait first, in both groups: the card that has waited longest is the one a person wants + // next, and a blocked card that has been stuck for days belongs above one stuck for minutes. + waiting.sort(oldestFirst) + blocked.sort(oldestFirst) return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } } } // #1828: the endpoint the host calls for a role switch. It answers the policy's own decision; diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index 62ceacd60..e8781c3e8 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -322,6 +322,24 @@ test('#1768: the pane draws each group and a blocked card shows its reason', () const ids = nodes.filter(node => node && node.props && node.props['data-task-id']).map(node => node.props['data-task-id']) assert.ok(ids.includes('task-w1') && ids.includes('task-b1') && ids.includes('task-g1'), JSON.stringify(ids)) }) + +// #2029: the blocked row said why and nothing about how long, so a card stuck three days and one +// stuck three minutes drew the same line. The host now hands the age over as `waitedMs`; the row +// draws it only when the host measured one, and a card with no stamp keeps the reason alone. +test('#2029: a blocked row says how long it has been stuck, and nothing when the host has no stamp', () => { + const aged = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: 1, waitedMs: 172800000 }] } + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, tasks: [], queues: aged })) + assert.equal(view.queues[2].items[0].waitedMs, 172800000) + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: aged })) + const reason = nodes.find(node => node && node.props && node.props.className === 'bt-project-reason') + assert.ok(reason, 'the blocked row draws its reason line') + assert.ok(textOf(reason).endsWith(' · 2d'), textOf(reason)) + // No stamp: the line is the reason alone — `0s` would claim a wait nobody measured. + const bare = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: null, waitedMs: null }] } + const bareNodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: bare })) + const bareReason = bareNodes.find(node => node && node.props && node.props.className === 'bt-project-reason') + assert.equal(textOf(bareReason), 'Which branch should this ship on?') +}) // #1814: the wait a card shows and whether its lease is live are computed at read time on the host // (`waitedMs`, `buildQueues`), so a board that reads once freezes: a lease that expires while the // page is open never turns `stalled` and `3h` stays `3h`. The board polls, and the count below is diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index 746abae13..70a47e65f 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' +import { blockedOf } from '../src/host/project-board.js' import { fakeContext } from './fake-ctx.js' const seed = () => ({ @@ -496,6 +497,49 @@ test('#1768: a blocked card carries its own reason and another Project is in non } finally { await stop() } }) +// #2029: the Blocked group answered why and never how long. `inputRequest.requestedAt` is the stamp +// the ask itself wrote (`tasks.js`), so a card's age is read from it — and from nowhere else: a +// fallback to `createdAt` would read as a wait nobody measured. +test('#2029: a blocked card carries the age of its own question', () => { + const NOW = 1_800_000_000_000 + const since = NOW - 48 * 3600e3 + assert.deepEqual( + blockedOf({ id: 't1', status: 'blocked', inputRequest: { id: 'q', question: 'why', requestedAt: since } }, NOW), + { id: 't1', reason: 'why', since, waitedMs: 172800000 }, + ) +}) + +test('#2029: a blocked card with no requestedAt has no age, invented or otherwise', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + // The card's own timestamps are there and a fallback would find them; what is missing is the + // question's stamp, so the card says nothing about how long it has been blocked. + const now = Date.now() + rt.state.tasks['task-blocked'].createdAt = now - 48 * 3600e3 + rt.state.tasks['task-blocked'].updatedAt = now - 48 * 3600e3 + const queues = await rt.projectBoardQueues('project-p1') + const item = queues.blocked.find(entry => entry.id === 'task-blocked') + assert.equal(item.since, null, 'no stamp, no age') + assert.equal(item.waitedMs, null, 'and no invented duration') + } finally { await stop() } +}) + +test('#2029: the Blocked group lists the card stuck longest first', async () => { + const team = queueSeed() + const now = Date.now() + team.tasks['task-blocked'].inputRequest.requestedAt = now - 3600e3 + team.tasks['task-blocked-old'] = { id: 'task-blocked-old', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Stuck since Friday', inputRequest: { id: 'q2', question: 'Still stuck?', requestedAt: now - 48 * 3600e3 }, createdAt: 41, updatedAt: 41 } + team.tasks['task-blocked-unstamped'] = { id: 'task-blocked-unstamped', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'No stamp', inputRequest: { id: 'q3', question: 'Since when?' }, createdAt: 42, updatedAt: 42 } + const { rt, stop } = await start(undefined, team) + try { + const queues = await rt.projectBoardQueues('project-p1') + // 48 h before 1 h, and the card the host cannot date sorts last rather than first. + assert.deepEqual(queues.blocked.map(item => item.id), ['task-blocked-old', 'task-blocked', 'task-blocked-unstamped']) + assert.deepEqual(queues.blocked.map(item => item.since), [now - 48 * 3600e3, now - 3600e3, null]) + assert.equal(queues.blocked[2].waitedMs, null) + } finally { await stop() } +}) + // #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line // whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate // — the queue reason is the case that reads a field another home supplied. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index f9f088295..aa6c3f8ca 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -389,7 +389,9 @@ hidden, and a read that lands after the pane closed is dropped. Tests: The pane draws the three host groups beside the columns (#1768) — `Waiting` with the role and how long the card waited, `In flight` with the holder and `running`/`stalled`, `Blocked` with the card's own -reason — and the header counts waiting / in flight / blocked / done. The groups and the counts are the +reason and, when the host measured one, how long it has been stuck (`reason · 2d`, from the row's +`waitedMs`; a card the host could not date keeps the reason alone) — and the header counts waiting / +in flight / blocked / done. The groups and the counts are the `project-board` answer, not a second read: `waiting`, `inflight`, `blocked` and `counts` ride along with `tasks` in the same response. A count the host answered as `null` renders as `unknown` and never as `0`; a group with no items is not drawn, and a board with neither rows nor groups is still the diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 4f0284bc9..b3618b947 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -211,7 +211,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. - One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 297868f7a..28dc2fce2 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -11625,7 +11625,7 @@ diff --git a/client.js b/client.js + }); + const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); -+ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null })); ++ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const queueGroups = [ + { group: "waiting", label: "Waiting", items: waiting }, + { group: "inflight", label: "In flight", items: inflight }, @@ -11882,7 +11882,7 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("h4", null, t(entry.label)), + entry.items.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); -+ const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason); ++ const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`); + return (0, import_react19.createElement)( + "div", + { diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 594b17389..996137811 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -311,7 +311,9 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi }) const waiting = queueItems(queues?.waiting, item => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) const inflight = queueItems(queues?.inflight, item => ({ holder: text(item.holder) || null, state: item.stalled === true ? 'stalled' : 'running' })) - const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null })) + // #2029: a blocked row carries the age the host measured beside its reason, the same way the + // waiting row does — the view never measures it here. + const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) const queueGroups = [ { group: 'waiting', label: 'Waiting', items: waiting }, { group: 'inflight', label: 'In flight', items: inflight }, @@ -503,7 +505,9 @@ function ProjectQueueGroups({ view, onOpen }) { ? h('span', { className: 'bt-project-wait' }, [row.role === null ? null : h('span', { key: 'role', className: 'bt-project-role' }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === 'inflight' ? h('span', { className: 'bt-project-state' }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) - : h('span', { className: 'bt-project-reason' }, row.reason) + // #2029: a blocked row says why and, when the host measured it, how long — `reason · 2d`. + // With no stamp it keeps the reason alone; an invented age would read as a wait nobody measured. + : h('span', { className: 'bt-project-reason' }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`) return h('div', { key: row.key, className: 'bt-project-row', diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index 7d9bf07aa..f875c541e 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -131,13 +131,22 @@ export function inflightOf(task) { return { id: String(task.id), holder } } -// Blocked: the reason is the card's own line, never a second copy. -export function blockedOf(task) { +// Blocked: the reason is the card's own line, never a second copy. #2029 adds the age the ask +// already stamped (`inputRequest.requestedAt`, `tasks.js`) — a card blocked for three days and one +// blocked for three minutes must not read alike. A card with no stamp has no age: the card's own +// `createdAt`/`updatedAt` are not the question's clock, and an invented wait is worse than none. +export function blockedOf(task, now) { if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || '' - return { id: String(task.id), reason } + const since = stampOf(task.inputRequest?.requestedAt) + return { id: String(task.id), reason, since, waitedMs: since === null ? null : Math.max(0, now - since) } } +// Oldest wait first: the card that has waited longest is the one a person wants next. Shared by the +// Waiting and the Blocked groups so the two read in the same order; a card the host cannot date +// sorts last rather than first. +const oldestFirst = (a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id) + // A decision is read for its title line only: the head of the file is enough for a heading, and // the body stays where it is. The bound also keeps a megabyte file from being read to answer one // line; the hash the reader needs comes from `listProjectState`, not from this read. @@ -504,11 +513,13 @@ export function install(rt) { const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live }) } - const block = blockedOf(task) + const block = blockedOf(task, now) if (block !== null) blocked.push(block) } - // Oldest wait first: the card that has waited longest is the one a person wants next. - waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id)) + // Oldest wait first, in both groups: the card that has waited longest is the one a person wants + // next, and a blocked card that has been stuck for days belongs above one stuck for minutes. + waiting.sort(oldestFirst) + blocked.sort(oldestFirst) return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } } } // #1828: the endpoint the host calls for a role switch. It answers the policy's own decision; diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index 62ceacd60..e8781c3e8 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -322,6 +322,24 @@ test('#1768: the pane draws each group and a blocked card shows its reason', () const ids = nodes.filter(node => node && node.props && node.props['data-task-id']).map(node => node.props['data-task-id']) assert.ok(ids.includes('task-w1') && ids.includes('task-b1') && ids.includes('task-g1'), JSON.stringify(ids)) }) + +// #2029: the blocked row said why and nothing about how long, so a card stuck three days and one +// stuck three minutes drew the same line. The host now hands the age over as `waitedMs`; the row +// draws it only when the host measured one, and a card with no stamp keeps the reason alone. +test('#2029: a blocked row says how long it has been stuck, and nothing when the host has no stamp', () => { + const aged = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: 1, waitedMs: 172800000 }] } + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, tasks: [], queues: aged })) + assert.equal(view.queues[2].items[0].waitedMs, 172800000) + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: aged })) + const reason = nodes.find(node => node && node.props && node.props.className === 'bt-project-reason') + assert.ok(reason, 'the blocked row draws its reason line') + assert.ok(textOf(reason).endsWith(' · 2d'), textOf(reason)) + // No stamp: the line is the reason alone — `0s` would claim a wait nobody measured. + const bare = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: null, waitedMs: null }] } + const bareNodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: bare })) + const bareReason = bareNodes.find(node => node && node.props && node.props.className === 'bt-project-reason') + assert.equal(textOf(bareReason), 'Which branch should this ship on?') +}) // #1814: the wait a card shows and whether its lease is live are computed at read time on the host // (`waitedMs`, `buildQueues`), so a board that reads once freezes: a lease that expires while the // page is open never turns `stalled` and `3h` stays `3h`. The board polls, and the count below is diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index 746abae13..70a47e65f 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' +import { blockedOf } from '../src/host/project-board.js' import { fakeContext } from './fake-ctx.js' const seed = () => ({ @@ -496,6 +497,49 @@ test('#1768: a blocked card carries its own reason and another Project is in non } finally { await stop() } }) +// #2029: the Blocked group answered why and never how long. `inputRequest.requestedAt` is the stamp +// the ask itself wrote (`tasks.js`), so a card's age is read from it — and from nowhere else: a +// fallback to `createdAt` would read as a wait nobody measured. +test('#2029: a blocked card carries the age of its own question', () => { + const NOW = 1_800_000_000_000 + const since = NOW - 48 * 3600e3 + assert.deepEqual( + blockedOf({ id: 't1', status: 'blocked', inputRequest: { id: 'q', question: 'why', requestedAt: since } }, NOW), + { id: 't1', reason: 'why', since, waitedMs: 172800000 }, + ) +}) + +test('#2029: a blocked card with no requestedAt has no age, invented or otherwise', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + // The card's own timestamps are there and a fallback would find them; what is missing is the + // question's stamp, so the card says nothing about how long it has been blocked. + const now = Date.now() + rt.state.tasks['task-blocked'].createdAt = now - 48 * 3600e3 + rt.state.tasks['task-blocked'].updatedAt = now - 48 * 3600e3 + const queues = await rt.projectBoardQueues('project-p1') + const item = queues.blocked.find(entry => entry.id === 'task-blocked') + assert.equal(item.since, null, 'no stamp, no age') + assert.equal(item.waitedMs, null, 'and no invented duration') + } finally { await stop() } +}) + +test('#2029: the Blocked group lists the card stuck longest first', async () => { + const team = queueSeed() + const now = Date.now() + team.tasks['task-blocked'].inputRequest.requestedAt = now - 3600e3 + team.tasks['task-blocked-old'] = { id: 'task-blocked-old', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Stuck since Friday', inputRequest: { id: 'q2', question: 'Still stuck?', requestedAt: now - 48 * 3600e3 }, createdAt: 41, updatedAt: 41 } + team.tasks['task-blocked-unstamped'] = { id: 'task-blocked-unstamped', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'No stamp', inputRequest: { id: 'q3', question: 'Since when?' }, createdAt: 42, updatedAt: 42 } + const { rt, stop } = await start(undefined, team) + try { + const queues = await rt.projectBoardQueues('project-p1') + // 48 h before 1 h, and the card the host cannot date sorts last rather than first. + assert.deepEqual(queues.blocked.map(item => item.id), ['task-blocked-old', 'task-blocked', 'task-blocked-unstamped']) + assert.deepEqual(queues.blocked.map(item => item.since), [now - 48 * 3600e3, now - 3600e3, null]) + assert.equal(queues.blocked[2].waitedMs, null) + } finally { await stop() } +}) + // #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line // whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate // — the queue reason is the case that reads a field another home supplied.