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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1901-task-text-code-points.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The task board's typed question and answer (2,000) and its handoff, evidence and lease bounds (300, 200, 200) now count characters rather than UTF-16 units ([#1901](https://github.com/ThinkFlowLab/FlyBot/issues/1901)), so a 2,000-character emoji question is accepted instead of refused as 4,000.
5 changes: 3 additions & 2 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { retiredBot } from './store.js'
import { currentParticipants, deriveParticipants } from './room-membership.js'
import { cardNamesIssue } from '../shared/issue-ref.js'
import { ownEntry } from './store.js'
import { codePoints } from './text.js'

const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked'])
// result-ready still waits for the requester: prune must not evict it with terminals (#111).
Expand Down Expand Up @@ -167,7 +168,7 @@ const readColumn = (value) => {
return column
}
const selectedInput = (value, name) => {
if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`)
if (typeof value !== 'string' || !value.trim() || codePoints(value) > 2000) throw invalid(`${name} must contain 1–2000 characters`)
return value
}

Expand Down Expand Up @@ -410,7 +411,7 @@ export function install(rt) {
const knownRole = role => RESERVED_ROLES.has(role) || Object.values(state.bots ?? {}).some(bot => Boolean(bot?.role) && sameRole(bot.role, role))
const boundedLine = (value, max, name) => {
const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
if (text.length > max) throw invalid(`${name} is at most ${max} characters`)
if (codePoints(text) > max) throw invalid(`${name} is at most ${max} characters`)
return text
}
const readTaskClass = (value) => {
Expand Down
37 changes: 37 additions & 0 deletions core/test/task-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,40 @@ test('the Project board reads the typed fields from the same row (#1720)', async
assert.equal(fresh.lease.holder, 'b1')
assert.equal(new Set(rows.map(row => row.id)).size, rows.length, 'one row per task id')
})

test('the handoff, evidence and lease bounds count characters, not UTF-16 units (#1901)', async (t) => {
const app = await start(t)
const byTitle = title => Object.values(app.rt.state.tasks).find(task => task.title === title)
// A 300-character handoff reason written in emoji is 600 UTF-16 units: exactly at the bound.
const reason = '😀'.repeat(300)
const handoff = await app.create({ title: 'A long reason', handoff: { from: 'Lead', to: 'Dev', reason } })
assert.equal(app.rt.state.tasks[handoff.id].handoff.reason, reason)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'One over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(301) } }),
/handoff\.reason is at most 300 characters/,
)
// Mixed text counts what the reader sees: 299 emoji + one ASCII is 300, one more is 301.
const mixed = '😀'.repeat(299) + 'x'
const mixedCard = await app.create({ title: 'A mixed reason', handoff: { from: 'Lead', to: 'Dev', reason: mixed } })
assert.equal(app.rt.state.tasks[mixedCard.id].handoff.reason, mixed)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Mixed over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(300) + 'x' } }),
/handoff\.reason is at most 300 characters/,
)
// evidence.check 200/201.
const check = '😀'.repeat(200)
await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long check', evidence: [{ check }] })
assert.equal(byTitle('A long check').evidence[0].check, check)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Check over', evidence: [{ check: '😀'.repeat(201) }] }),
/evidence\.check is at most 200 characters/,
)
// lease.holder 200/201.
const holder = '😀'.repeat(200)
await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long holder', lease: { holder, takenAt: 1, expiresAt: 2 } })
assert.equal(byTitle('A long holder').lease.holder, holder)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Holder over', lease: { holder: '😀'.repeat(201), takenAt: 1, expiresAt: 2 } }),
/lease\.holder is at most 200 characters/,
)
})
33 changes: 33 additions & 0 deletions core/test/tasks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,36 @@ test('sending a result back names the note in the comment, and a task without a
const saved = JSON.parse(readFileSync(app.statePath, 'utf8')).tasks[plainId]
assert.equal(saved.writeBack, undefined)
})

test('a typed question and answer count characters, not UTF-16 units (#1901)', async (t) => {
const app = await start(t)
t.after(() => app.stop())
const alice = await app.signIn('Alice')
const id = await workingInputTask(app, alice.sessionToken)
const emoji = '😀'.repeat(2000)
// 2,000 characters written in emoji is 4,000 UTF-16 units: exactly at the bound.
const asked = await app.call('task-wait-input', { id, question: emoji }, alice.sessionToken)
assert.equal(asked.ok, true, JSON.stringify(asked))
assert.equal(asked.value.inputRequest.question, emoji)
const answer = await app.call('task-answer-input', { id, questionId: asked.value.inputRequest.id, answer: emoji }, alice.sessionToken)
assert.equal(answer.ok, true, JSON.stringify(answer))
assert.equal(answer.value.inputRequest.answer, emoji)
// One character past the bound is refused, and the sentence still names the bound it kept.
const overQuestion = await app.call('task-wait-input', { id, question: '😀'.repeat(2001) }, alice.sessionToken)
assert.equal(overQuestion.ok, false)
assert.equal(overQuestion.error.code, 'bot/invalid')
assert.match(overQuestion.error.message, /Question must contain 1–2000 characters/)
// Mixed text counts what the reader sees, so a "halve the limit" fix cannot pass.
const mixed = '😀'.repeat(1999) + 'x'
const mixedAsked = await app.call('task-wait-input', { id, question: mixed }, alice.sessionToken)
assert.equal(mixedAsked.ok, true, JSON.stringify(mixedAsked))
const overAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: '😀'.repeat(2001) }, alice.sessionToken)
assert.equal(overAnswer.ok, false)
assert.equal(overAnswer.error.code, 'bot/invalid')
assert.match(overAnswer.error.message, /Answer must contain 1–2000 characters/)
const mixedAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: mixed }, alice.sessionToken)
assert.equal(mixedAnswer.ok, true, JSON.stringify(mixedAnswer))
const overMixed = await app.call('task-wait-input', { id, question: '😀'.repeat(2000) + 'x' }, alice.sessionToken)
assert.equal(overMixed.ok, false)
assert.match(overMixed.error.message, /Question must contain 1–2000 characters/)
})
4 changes: 2 additions & 2 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ A tracked request with a stable id (`task-<hex>`) and a small state machine, own

### The typed task contract (#1720)

Five additive fields sit beside the record above; none changes what an existing field means, and a record stored before this slice reads back with them absent and nothing rewritten. `taskClass` is the typed kind (`work | rework | release | triage | meeting`) — it is not `kind`, which keeps its group-card meaning, so a group card is `kind: 'group'` with `taskClass: 'work'`. `reworkOf` names the card a rework card fixes and is not `parentId`, which stays the one-level part-of-overall link (`settleOverall` is untouched); a rework card therefore fires no overall-card settle. `handoff` is `{from, to, at, reason}` with both roles named (a role is known when a Bot carries it, or it is `requester`; an unknown role is refused with the role quoted back), `reason` at most 300 characters. `evidence` is at most eight `{sha, pr, check, at}` pointers, and `lease` is `{holder, takenAt, expiresAt}` on a claimed card. Every value is validated on write by one function, `writeContract` in `src/host/tasks.js`, reached from `track_tasks`, `task-create` and `task-update`; it reads every field before it assigns one, so a refusal (`bot/invalid`, or `bot/unknown-role` for a role no Bot carries) leaves the card exactly as it was. A review rejection (`task-send-back`) records the handover on the original, moves it to `working` and opens exactly one child card with `taskClass: 'rework'` and `reworkOf` naming the original — the single write that returns the original to `result-ready` is the acceptance of that child, guarded by the original still being `working`, so a replay creates no second child and moves the original once. A claim takes a lease (30 minutes); a claim on a live lease is refused as `bot/lease-held` with the holder named, and an expired lease returns a queued card to the queue. The read model does not copy: `project-board.js` puts the five fields on the row it already emits when the card carries them, so a pre-slice card keeps its old row shape. Tests: `core/test/task-contract.test.js`.
Five additive fields sit beside the record above; none changes what an existing field means, and a record stored before this slice reads back with them absent and nothing rewritten. `taskClass` is the typed kind (`work | rework | release | triage | meeting`) — it is not `kind`, which keeps its group-card meaning, so a group card is `kind: 'group'` with `taskClass: 'work'`. `reworkOf` names the card a rework card fixes and is not `parentId`, which stays the one-level part-of-overall link (`settleOverall` is untouched); a rework card therefore fires no overall-card settle. `handoff` is `{from, to, at, reason}` with both roles named (a role is known when a Bot carries it, or it is `requester`; an unknown role is refused with the role quoted back), `reason` at most 300 characters. These bounds count code points, not UTF-16 units (#1901), so a 300-character emoji reason is stored rather than refused as 600. `evidence` is at most eight `{sha, pr, check, at}` pointers, and `lease` is `{holder, takenAt, expiresAt}` on a claimed card. Every value is validated on write by one function, `writeContract` in `src/host/tasks.js`, reached from `track_tasks`, `task-create` and `task-update`; it reads every field before it assigns one, so a refusal (`bot/invalid`, or `bot/unknown-role` for a role no Bot carries) leaves the card exactly as it was. A review rejection (`task-send-back`) records the handover on the original, moves it to `working` and opens exactly one child card with `taskClass: 'rework'` and `reworkOf` naming the original — the single write that returns the original to `result-ready` is the acceptance of that child, guarded by the original still being `working`, so a replay creates no second child and moves the original once. A claim takes a lease (30 minutes); a claim on a live lease is refused as `bot/lease-held` with the holder named, and an expired lease returns a queued card to the queue. The read model does not copy: `project-board.js` puts the five fields on the row it already emits when the card carries them, so a pre-slice card keeps its old row shape. Tests: `core/test/task-contract.test.js`.

### Task input waits

Expand All @@ -260,7 +260,7 @@ working task (#207). It records `blocked`, `waitingFor: 'input'` and a fresh
`inputRequest { id, question, requestedAt }`. `task-answer-input { id, questionId,
answer }` requires that exact current question, the same requester and no pending
task approvals before returning to `working`. Question/answer must be nonblank
strings of at most 2,000 characters. Only the latest selected question/answer
strings of at most 2,000 characters, counted in code points (#1901). Only the latest selected question/answer
snapshot is kept, with actor entries linking its question ID; chat history is not
copied. The wait and snapshot survive restart.

Expand Down
5 changes: 3 additions & 2 deletions patches/ds-bot/files/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { retiredBot } from './store.js'
import { currentParticipants, deriveParticipants } from './room-membership.js'
import { cardNamesIssue } from '../shared/issue-ref.js'
import { ownEntry } from './store.js'
import { codePoints } from './text.js'

const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked'])
// result-ready still waits for the requester: prune must not evict it with terminals (#111).
Expand Down Expand Up @@ -167,7 +168,7 @@ const readColumn = (value) => {
return column
}
const selectedInput = (value, name) => {
if (typeof value !== 'string' || !value.trim() || value.length > 2000) throw invalid(`${name} must contain 1–2000 characters`)
if (typeof value !== 'string' || !value.trim() || codePoints(value) > 2000) throw invalid(`${name} must contain 1–2000 characters`)
return value
}

Expand Down Expand Up @@ -410,7 +411,7 @@ export function install(rt) {
const knownRole = role => RESERVED_ROLES.has(role) || Object.values(state.bots ?? {}).some(bot => Boolean(bot?.role) && sameRole(bot.role, role))
const boundedLine = (value, max, name) => {
const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
if (text.length > max) throw invalid(`${name} is at most ${max} characters`)
if (codePoints(text) > max) throw invalid(`${name} is at most ${max} characters`)
return text
}
const readTaskClass = (value) => {
Expand Down
37 changes: 37 additions & 0 deletions patches/ds-bot/files/test/task-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,40 @@ test('the Project board reads the typed fields from the same row (#1720)', async
assert.equal(fresh.lease.holder, 'b1')
assert.equal(new Set(rows.map(row => row.id)).size, rows.length, 'one row per task id')
})

test('the handoff, evidence and lease bounds count characters, not UTF-16 units (#1901)', async (t) => {
const app = await start(t)
const byTitle = title => Object.values(app.rt.state.tasks).find(task => task.title === title)
// A 300-character handoff reason written in emoji is 600 UTF-16 units: exactly at the bound.
const reason = '😀'.repeat(300)
const handoff = await app.create({ title: 'A long reason', handoff: { from: 'Lead', to: 'Dev', reason } })
assert.equal(app.rt.state.tasks[handoff.id].handoff.reason, reason)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'One over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(301) } }),
/handoff\.reason is at most 300 characters/,
)
// Mixed text counts what the reader sees: 299 emoji + one ASCII is 300, one more is 301.
const mixed = '😀'.repeat(299) + 'x'
const mixedCard = await app.create({ title: 'A mixed reason', handoff: { from: 'Lead', to: 'Dev', reason: mixed } })
assert.equal(app.rt.state.tasks[mixedCard.id].handoff.reason, mixed)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Mixed over', handoff: { from: 'Lead', to: 'Dev', reason: '😀'.repeat(300) + 'x' } }),
/handoff\.reason is at most 300 characters/,
)
// evidence.check 200/201.
const check = '😀'.repeat(200)
await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long check', evidence: [{ check }] })
assert.equal(byTitle('A long check').evidence[0].check, check)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Check over', evidence: [{ check: '😀'.repeat(201) }] }),
/evidence\.check is at most 200 characters/,
)
// lease.holder 200/201.
const holder = '😀'.repeat(200)
await app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'A long holder', lease: { holder, takenAt: 1, expiresAt: 2 } })
assert.equal(byTitle('A long holder').lease.holder, holder)
await assert.rejects(
() => app.rt.taskEndpoint('task-create', { botId: 'd1', title: 'Holder over', lease: { holder: '😀'.repeat(201), takenAt: 1, expiresAt: 2 } }),
/lease\.holder is at most 200 characters/,
)
})
33 changes: 33 additions & 0 deletions patches/ds-bot/files/test/tasks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,36 @@ test('sending a result back names the note in the comment, and a task without a
const saved = JSON.parse(readFileSync(app.statePath, 'utf8')).tasks[plainId]
assert.equal(saved.writeBack, undefined)
})

test('a typed question and answer count characters, not UTF-16 units (#1901)', async (t) => {
const app = await start(t)
t.after(() => app.stop())
const alice = await app.signIn('Alice')
const id = await workingInputTask(app, alice.sessionToken)
const emoji = '😀'.repeat(2000)
// 2,000 characters written in emoji is 4,000 UTF-16 units: exactly at the bound.
const asked = await app.call('task-wait-input', { id, question: emoji }, alice.sessionToken)
assert.equal(asked.ok, true, JSON.stringify(asked))
assert.equal(asked.value.inputRequest.question, emoji)
const answer = await app.call('task-answer-input', { id, questionId: asked.value.inputRequest.id, answer: emoji }, alice.sessionToken)
assert.equal(answer.ok, true, JSON.stringify(answer))
assert.equal(answer.value.inputRequest.answer, emoji)
// One character past the bound is refused, and the sentence still names the bound it kept.
const overQuestion = await app.call('task-wait-input', { id, question: '😀'.repeat(2001) }, alice.sessionToken)
assert.equal(overQuestion.ok, false)
assert.equal(overQuestion.error.code, 'bot/invalid')
assert.match(overQuestion.error.message, /Question must contain 1–2000 characters/)
// Mixed text counts what the reader sees, so a "halve the limit" fix cannot pass.
const mixed = '😀'.repeat(1999) + 'x'
const mixedAsked = await app.call('task-wait-input', { id, question: mixed }, alice.sessionToken)
assert.equal(mixedAsked.ok, true, JSON.stringify(mixedAsked))
const overAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: '😀'.repeat(2001) }, alice.sessionToken)
assert.equal(overAnswer.ok, false)
assert.equal(overAnswer.error.code, 'bot/invalid')
assert.match(overAnswer.error.message, /Answer must contain 1–2000 characters/)
const mixedAnswer = await app.call('task-answer-input', { id, questionId: mixedAsked.value.inputRequest.id, answer: mixed }, alice.sessionToken)
assert.equal(mixedAnswer.ok, true, JSON.stringify(mixedAnswer))
const overMixed = await app.call('task-wait-input', { id, question: '😀'.repeat(2000) + 'x' }, alice.sessionToken)
assert.equal(overMixed.ok, false)
assert.match(overMixed.error.message, /Question must contain 1–2000 characters/)
})
Loading