Skip to content

Commit 0972daa

Browse files
authored
Accept a result or send it back from the Inbox Done list (#401) (#1155)
Owner: Dev2 Reviewer: Reviewer Tip SHA: 0376e73 Fixes #401 Related #1154 ## Reproduce A finished task is a *Needs you* card in the Inbox with one action, **Open**. The host has `task-accept-result` (`core/src/host/tasks.js`) but nothing calls it — `core/src/client/index.js` exposes no `acceptTaskResult` and `inbox.js` draws no result action — so the requester cannot accept from the card. There is no send-back endpoint at all: the only way to reject a result is `task-cancel`, which throws the work away instead of telling the Bot what to change. A result also cannot carry evidence (`task-return` stores `{kind, summary}` only), and the card shows neither the evidence nor which Bot/model produced it. Tests are the three new cases: `core/test/tasks.test.js` #27 (`a returned result carries at most five http(s) links…`) and #28 (`the requester sends a result back with a note…`), `core/test/inbox.test.js` #24 (`a result card is accepted or sent back in place…`). ## What changed - `core/src/host/tasks.js` — `result-ready` may now also go to `working`; `task-return` reads evidence links (`readLinks`: at most five `http(s)` URLs, deduped, in order, the rest dropped; a non-list refused **before** the transition so a malformed write leaves the task where it is) into `result.links`; new `task-send-back` needs a note (1..500, `bot/invalid` without one), requires `result-ready`, moves the same task to `working` with `followUp: {note, at}` and the `by: 'sent back'` actor entry. - `core/src/host/authz.js`, `core/src/host/channel.js` — `task-send-back` in the explicit allow-list and in the channel's `task-*` case list. - `core/src/client/index.js` — `acceptTaskResult(id)` / `sendBackTask(id, note)`. - `core/src/client/inbox.js` — `inboxAnswer` answers a `status: 'result'` card with `'accept'` or `{note}`; a `ResultActions` row (**Send back** → one-line box `What should change` → **Send it back**, plus **Accept**), the box kept in a card-keyed slot so a roster or clock poll does not close it (same shape as #1140's short answer); `task.result.links` drawn as evidence links; `task.followUp.note` drawn as *Sent back: …*; the producing Bot's model added to the card's meta line; four CSS rules. - `core/src/client/i18n.js` — Chinese copy for the five new strings, so `core/test/i18n.test.js`'s "every literal the client translates has a Chinese entry" still holds. - `core/client.js` — rebuilt from `core/src/client/` (`node scripts/build-client.mjs`). - `patches/ds-bot/` — the series regenerated for the touched files (`files/` verbatim copies; `0001`/`0003`/`0005`/`0008` sections rewritten by `diff -u`, see Risk). - `docs/subsystems/core-host.md` (endpoints, `result.links`, `task-send-back`), `docs/subsystems/core-client.md` (the inbox row and a *Done is the acceptance surface* invariant), `docs/subsystems/team.md` (channel allow-list). - `changelog.d/401-accept-or-send-back-a-result.added.md` — user-visible. Not in this PR: writing the verdict back to the issue/PR the task came from (the other half of the owner's phase 1 — a task has no pointer to an issue/PR and the host has no GitHub write path). Tracked as #1154, so **judge this tip as the card + record half**, not as phase 1 complete. ## What I ran On this tip, base `75ca7ab9`, no `git` on this box: - `cd core && node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/tasks.test.js test/inbox.test.js test/i18n.test.js test/at-menu.test.js test/reach-policy.test.js test/bot-tasks.test.js test/kanban.test.js` → `# tests 88 / pass 88 / fail 0 / skipped 0` - `cd core && node scripts/build-client.mjs && node scripts/build-client.mjs --check` → exit 0 both - `node scripts/ds-bot-patches.mjs check` → `skipped pin (no git): the tree hash cannot be computed here`, `ok 10 patches apply cleanly in order`, `ok core/ equals ds-bot/ + patches/ds-bot/` - `cd core && DSH_HARNESS=… node scripts/lint.mjs` → `Found 51 warnings and 0 errors.` (the same 51 as base `75ca7ab9`; none in the files this PR touches), then `arch-docs: FAIL — cannot diff against origin/main` (no git here) - `node scripts/lint-plugins.mjs` → ok - `node .github/scripts/changelog.mjs check` → `39 fragment(s) in changelog.d/ are well formed` - `diff -rq` against a `75ca7ab9` tarball → exactly the 22 files of this PR, nothing else (no ghost) ## Red without the source change - Host: copy base `75ca7ab9`'s `core/src/host/tasks.js` over this tip's → `not ok 27` and `not ok 28`, `# pass 26 / # fail 2`; restored, `cmp` identical. - Client: copy base `core/src/client/inbox.js` over this tip's → `not ok 24` (`a result card is accepted or sent back in place…`), `# pass 23 / # fail 1`; restored, `cmp` identical, `ds-bot-patches check` still both `ok`. ## Not run - `npm run verify` end to end: its `arch-docs` step needs `git` and `origin/main`, which this box does not have. Both halves were run by hand (above); CI's `tests-ok` is the terminal answer. - The whole suite on this exact tip is in the body above; the eight failures it reports are the same eight a pristine `75ca7ab9` reports on this box (see the comment below). ## Risk - `patches/ds-bot/0008-client-bundle.patch` is rewritten with `diff -u` rather than `git diff` (no git here), so its hunk boundaries differ from git's even though the content is the same: `check` proves `core/client.js` still equals `ds-bot/ + patches`. The next Bot that regenerates this file with git will rewrite that section — the same shape as #1141's regeneration. - A sent-back task keeps its previous `result` on the record (deliberate: the note points at what was already there; the next `task-return` overwrites it). - `task-send-back` is a new channel name: an older host answers `bot/not-found` and the client shows that error on the card; an older client never calls it.
1 parent c1f2edc commit 0972daa

22 files changed

Lines changed: 3314 additions & 6048 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A finished task can now be accepted or sent back right in the inbox ([#401](https://github.com/ThinkFlowLab/FlyBot/issues/401)): the result card shows the Bot's evidence links and model, **Accept** closes the task as done, and **Send back** asks for one line about what should change and returns the same task to the Bot.

‎core/client.js‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,12 @@ var ZH = {
677677
"Latest: {text}": "最新:{text}",
678678
"{count} tool calls": "{count} 次工具调用",
679679
"Untitled task": "未命名任务",
680+
// Accepting a result in Done (#401)
681+
Accept: "接受",
682+
"Send back": "退回",
683+
"Send it back": "退回给 Bot",
684+
"What should change": "需要改什么",
685+
"Sent back: {note}": "已退回:{note}",
680686
// Bot task board (#1058)
681687
Tasks: "任务",
682688
"Close tasks": "关闭任务",
@@ -7539,6 +7545,12 @@ function inboxAnswer(actions, card, choice) {
75397545
if (text === "" || typeof questionId !== "string") return void 0;
75407546
return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text));
75417547
}
7548+
if (card?.kind === "task" && card.status === "result") {
7549+
if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id));
7550+
const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : "";
7551+
if (note === "") return void 0;
7552+
return Promise.resolve(actions.sendBackTask(card.task.id, note));
7553+
}
75427554
if (card?.kind === "approval" && card.approval?.answerable === true) {
75437555
return Promise.resolve(actions.answerApproval(card.approval.id, choice));
75447556
}
@@ -7553,6 +7565,51 @@ var answerSlot = (id) => {
75537565
}
75547566
return slot;
75557567
};
7568+
var resultSlots = /* @__PURE__ */ new Map();
7569+
var resultSlot = (id) => {
7570+
let slot = resultSlots.get(id);
7571+
if (slot === void 0) {
7572+
slot = { open: false, text: "" };
7573+
resultSlots.set(id, slot);
7574+
}
7575+
return slot;
7576+
};
7577+
function ResultActions({ card, busy, onAnswer }) {
7578+
const [, redraw] = (0, import_react13.useState)(0);
7579+
if (!(card.kind === "task" && card.status === "result")) return null;
7580+
const slot = resultSlot(card.id);
7581+
const send = () => {
7582+
const note = slot.text.trim();
7583+
if (busy || note === "") return;
7584+
onAnswer(card, { note });
7585+
};
7586+
return (0, import_react13.createElement)(
7587+
"span",
7588+
{ className: "bt-inbox-result" },
7589+
slot.open ? (0, import_react13.createElement)("input", {
7590+
type: "text",
7591+
className: "bt-inbox-answer-text",
7592+
value: slot.text,
7593+
disabled: busy,
7594+
"aria-label": t("What should change"),
7595+
placeholder: t("What should change"),
7596+
onChange: (event) => {
7597+
slot.text = String(event.target?.value ?? "");
7598+
redraw((count) => count + 1);
7599+
},
7600+
onKeyDown: (event) => {
7601+
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
7602+
event.preventDefault();
7603+
send();
7604+
}
7605+
}) : null,
7606+
slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
7607+
slot.open = true;
7608+
redraw((count) => count + 1);
7609+
} }, t("Send back")),
7610+
(0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept"))
7611+
);
7612+
}
75567613
function AnswerFields({ card, busy, onAnswer }) {
75577614
const [, redraw] = (0, import_react13.useState)(0);
75587615
const question = card.kind === "question";
@@ -7648,8 +7705,11 @@ function whenText(card, now) {
76487705
}
76497706
function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
76507707
const who = card.bot ? card.bot.name : card.room?.name ?? "";
7651-
const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(" · ");
7708+
const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : [];
7709+
const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · ");
76527710
const answerable = card.kind === "approval" && card.approval?.answerable === true;
7711+
const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : [];
7712+
const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0;
76537713
return (0, import_react13.createElement)(
76547714
"div",
76557715
{ className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status },
@@ -7668,7 +7728,17 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
76687728
card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null,
76697729
card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null,
76707730
card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null,
7731+
evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", {
7732+
key: `${href}-${index}`,
7733+
href,
7734+
target: "_blank",
7735+
rel: "noreferrer noopener",
7736+
className: "bt-inbox-evidence-link",
7737+
title: href
7738+
}, href))) : null,
7739+
sentBack ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: sentBack }, t("Sent back: {note}", { note: sentBack })) : null,
76717740
(0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }),
7741+
(0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }),
76727742
error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null
76737743
),
76747744
(0, import_react13.createElement)(
@@ -7802,6 +7872,11 @@ var INBOX_CSS = `
78027872
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
78037873
.bt-inbox-activity{color:var(--bt-ink-3)}
78047874
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
7875+
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
7876+
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
7877+
.bt-inbox-evidence-link:hover{text-decoration:underline}
7878+
.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
7879+
.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px}
78057880
.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
78067881
.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px}
78077882
.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px}
@@ -18866,6 +18941,10 @@ function apply(ctx) {
1886618941
send: (sessionId, text) => after(call("send", { sessionId: partFor(sessionId), text })),
1886718942
answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })),
1886818943
answerTaskInput: (id, questionId, answer) => after(call("task-answer-input", { id, questionId, answer })),
18944+
// The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note
18945+
// that returns the same task to the Bot.
18946+
acceptTaskResult: (id) => after(call("task-accept-result", { id })),
18947+
sendBackTask: (id, note) => after(call("task-send-back", { id, note })),
1886918948
dismissQuestion: (sessionId) => after(call("dismiss-question", { sessionId })),
1887018949
answerApproval: (id, choice) => after(call("approval-answer", { id, choice })),
1887118950
removeStandingApproval: (scope, id, kind) => after(call("standing-approval-remove", { scope, id, kind })),

‎core/src/client/i18n.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,12 @@ const ZH = {
618618
'Latest: {text}': '最新:{text}',
619619
'{count} tool calls': '{count} 次工具调用',
620620
'Untitled task': '未命名任务',
621+
// Accepting a result in Done (#401)
622+
Accept: '接受',
623+
'Send back': '退回',
624+
'Send it back': '退回给 Bot',
625+
'What should change': '需要改什么',
626+
'Sent back: {note}': '已退回:{note}',
621627
// Bot task board (#1058)
622628
Tasks: '任务',
623629
'Close tasks': '关闭任务',

‎core/src/client/inbox.js‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,12 @@ export function inboxAnswer(actions, card, choice) {
295295
if (text === '' || typeof questionId !== 'string') return undefined
296296
return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text))
297297
}
298+
if (card?.kind === 'task' && card.status === 'result') {
299+
if (choice === 'accept') return Promise.resolve(actions.acceptTaskResult(card.task.id))
300+
const note = choice !== null && typeof choice === 'object' ? String(choice.note ?? '').trim() : ''
301+
if (note === '') return undefined
302+
return Promise.resolve(actions.sendBackTask(card.task.id, note))
303+
}
298304
if (card?.kind === 'approval' && card.approval?.answerable === true) {
299305
return Promise.resolve(actions.answerApproval(card.approval.id, choice))
300306
}
@@ -313,6 +319,45 @@ const answerSlot = (id) => {
313319
return slot
314320
}
315321

322+
// The Done list is the acceptance surface (#401): a result card is accepted here, or sent back with a
323+
// note that returns the same task to the Bot. The box opens in place, and the note survives a roster poll
324+
// because the slot is keyed by the card, like the answer slot above (#1140).
325+
const resultSlots = new Map()
326+
const resultSlot = (id) => {
327+
let slot = resultSlots.get(id)
328+
if (slot === undefined) {
329+
slot = { open: false, text: '' }
330+
resultSlots.set(id, slot)
331+
}
332+
return slot
333+
}
334+
335+
function ResultActions({ card, busy, onAnswer }) {
336+
const [, redraw] = useState(0)
337+
if (!(card.kind === 'task' && card.status === 'result')) return null
338+
const slot = resultSlot(card.id)
339+
const send = () => {
340+
const note = slot.text.trim()
341+
if (busy || note === '') return
342+
onAnswer(card, { note })
343+
}
344+
return h('span', { className: 'bt-inbox-result' },
345+
slot.open ? h('input', {
346+
type: 'text', className: 'bt-inbox-answer-text', value: slot.text, disabled: busy,
347+
'aria-label': t('What should change'), placeholder: t('What should change'),
348+
onChange: (event) => { slot.text = String(event.target?.value ?? ''); redraw(count => count + 1) },
349+
onKeyDown: (event) => {
350+
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
351+
event.preventDefault()
352+
send()
353+
},
354+
}) : null,
355+
slot.open
356+
? h('button', { type: 'button', className: 'bt-send bt-soft-sm', disabled: busy, onClick: send }, t('Send it back'))
357+
: h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { slot.open = true; redraw(count => count + 1) } }, t('Send back')),
358+
h('button', { type: 'button', className: 'bt-send bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'accept') }, t('Accept')))
359+
}
360+
316361
// Options and a short answer, drawn inside the card so a click answers here.
317362
function AnswerFields({ card, busy, onAnswer }) {
318363
const [, redraw] = useState(0)
@@ -394,8 +439,11 @@ function whenText(card, now) {
394439

395440
export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) {
396441
const who = card.bot ? card.bot.name : card.room?.name ?? ''
397-
const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(' · ')
442+
const produced = card.kind === 'task' && card.status === 'result' ? [card.bot?.model] : []
443+
const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(' · ')
398444
const answerable = card.kind === 'approval' && card.approval?.answerable === true
445+
const evidence = card.kind === 'task' && card.status === 'result' ? (card.task?.result?.links ?? []) : []
446+
const sentBack = card.kind === 'task' && card.status === 'task' ? card.task?.followUp?.note : undefined
399447
return h('div', { className: 'bt-inbox-card', role: 'group', 'aria-label': `${t(STATUSES[card.status].label)}: ${card.title}`, 'data-status': card.status },
400448
h('span', { className: 'bt-inbox-ava' }, card.bot ? h(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? h(RoomAvatar, { room: card.room, roster, size: 28 }) : null),
401449
h('span', { className: 'bt-inbox-copy' },
@@ -407,7 +455,12 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen })
407455
card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null,
408456
card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null,
409457
card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null,
458+
evidence.length > 0 ? h('span', { className: 'bt-inbox-evidence' }, ...evidence.map((href, index) => h('a', {
459+
key: `${href}-${index}`, href, target: '_blank', rel: 'noreferrer noopener', className: 'bt-inbox-evidence-link', title: href,
460+
}, href))) : null,
461+
sentBack ? h('span', { className: 'bt-inbox-reason', title: sentBack }, t('Sent back: {note}', { note: sentBack })) : null,
410462
h(AnswerFields, { card, busy, onAnswer }),
463+
h(ResultActions, { card, busy, onAnswer }),
411464
error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null),
412465
h('span', { className: 'bt-inbox-actions' },
413466
answerable ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => onAnswer(card, 'reject') }, t('Reject')) : null,
@@ -523,6 +576,11 @@ export const INBOX_CSS = `
523576
.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
524577
.bt-inbox-activity{color:var(--bt-ink-3)}
525578
.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
579+
.bt-inbox-evidence{display:flex;flex-wrap:wrap;gap:6px;font-size:12px;line-height:16px}
580+
.bt-inbox-evidence-link{color:var(--bt-blue,#3a7afe);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
581+
.bt-inbox-evidence-link:hover{text-decoration:underline}
582+
.bt-inbox-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
583+
.bt-inbox-result .bt-send,.bt-inbox-result .bt-soft{height:28px;font-size:12px}
526584
.bt-inbox-answer{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}
527585
.bt-inbox-answer-text{flex:1 1 140px;min-width:0;height:28px;padding:0 8px;border:.5px solid var(--bt-line-2);border-radius:8px;background:transparent;color:var(--bt-ink);font:inherit;font-size:12px}
528586
.bt-inbox-answer .bt-send,.bt-inbox-answer .bt-soft{height:28px;font-size:12px}

‎core/src/client/index.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,10 @@ export function apply(ctx) {
582582
send: (sessionId, text) => after(call('send', { sessionId: partFor(sessionId), text })),
583583
answerQuestion: (sessionId, questionId, answer) => after(call('answer-question', { sessionId: partFor(sessionId), questionId, answer })),
584584
answerTaskInput: (id, questionId, answer) => after(call('task-answer-input', { id, questionId, answer })),
585+
// The Done list is the acceptance surface (#401): a result is accepted, or sent back with a note
586+
// that returns the same task to the Bot.
587+
acceptTaskResult: id => after(call('task-accept-result', { id })),
588+
sendBackTask: (id, note) => after(call('task-send-back', { id, note })),
585589
dismissQuestion: sessionId => after(call('dismiss-question', { sessionId })),
586590
answerApproval: (id, choice) => after(call('approval-answer', { id, choice })),
587591
removeStandingApproval: (scope, id, kind) => after(call('standing-approval-remove', { scope, id, kind })),

‎core/src/host/authz.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss
5858
// Task channel names. Requester checks live on the record in tasks.js; listing them
5959
// here keeps a future `task-*` from auto-allowing for any signed-in actor (#111).
6060
const TASK_ENDPOINTS = new Set([
61-
'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-cancel',
61+
'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-send-back', 'task-cancel',
6262
'task-wait-input', 'task-answer-input',
6363
])
6464

‎core/src/host/channel.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ export function install(rt) {
633633
case 'task-start':
634634
case 'task-return':
635635
case 'task-accept-result':
636+
case 'task-send-back':
636637
case 'task-cancel':
637638
case 'task-wait-input':
638639
case 'task-answer-input':

‎core/src/host/tasks.js‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const TRANSITIONS = {
2727
claimed: ['working', 'cancelled', 'failed'],
2828
working: ['blocked', 'result-ready', 'cancelled', 'failed'],
2929
blocked: ['working', 'cancelled', 'failed'],
30-
'result-ready': ['accepted', 'cancelled'],
30+
'result-ready': ['accepted', 'working', 'cancelled'],
3131
accepted: [],
3232
cancelled: [],
3333
failed: [],
@@ -57,6 +57,21 @@ const readProgress = (value) => {
5757
}
5858
const oneLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max)
5959
const readTitle = value => oneLine(value, 300)
60+
// Evidence a result carries (#401): links only, http(s), a handful, so a card can point at the diff, the
61+
// file or the test run without the record becoming a transcript.
62+
const LINKS_MAX = 5
63+
const readLinks = (value) => {
64+
if (value === undefined || value === null) return []
65+
if (!Array.isArray(value)) throw invalid('Result links must be a list')
66+
const links = []
67+
for (const item of value) {
68+
const link = oneLine(item, 300)
69+
if (!/^https?:\/\//i.test(link) || links.includes(link)) continue
70+
links.push(link)
71+
if (links.length === LINKS_MAX) break
72+
}
73+
return links
74+
}
6075
const readNote = (value) => {
6176
if (value === undefined || value === null) return undefined
6277
return oneLine(value, 300)
@@ -594,8 +609,10 @@ export function install(rt) {
594609
const task = find(body.id)
595610
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
596611
requireRequester(task, actor)
612+
// Read the evidence before the transition so a malformed write leaves the task where it is.
613+
const links = readLinks(body.links)
597614
transition(task, 'result-ready', actor)
598-
task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000) }
615+
task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000), ...(links.length ? { links } : {}) }
599616
await save()
600617
return view(task)
601618
}
@@ -632,6 +649,18 @@ export function install(rt) {
632649
await save()
633650
return view(task)
634651
}
652+
case 'task-send-back': {
653+
const task = find(body.id)
654+
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)
655+
requireRequester(task, actor)
656+
if (task.status !== 'result-ready') throw stale('Only a result waiting for the requester can be sent back')
657+
const note = oneLine(body.note, 500)
658+
if (note === '') throw invalid('Send back needs a note for the Bot')
659+
transition(task, 'working', actor, { by: 'sent back' })
660+
task.followUp = { note, at: task.updatedAt }
661+
await save()
662+
return view(task)
663+
}
635664
case 'task-cancel': {
636665
const task = find(body.id)
637666
if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`)

0 commit comments

Comments
 (0)