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/1730-task-audit.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Project board card now carries an `audit` block: the handoff, the evidence, the rework chain and the current lease rendered from the task record itself, with no new endpoint or page. A rework cycle is counted as one accepted delivery instead of two, so the counters no longer double after a rejection.
12 changes: 11 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19621,6 +19621,8 @@ var PROJECT_BOARD_CSS = `
.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
.bt-project-row[data-column="done"]{opacity:.7}
.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
.bt-project-empty{padding:22px 4px;opacity:.7}
.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
Expand Down Expand Up @@ -19657,6 +19659,7 @@ function projectBoardView({ project, rows, canManage, tasks } = {}) {
nodeId: text(row.nodeId) || null,
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
audit: text(row.audit) || null,
board: boardOfRow(row, task)
};
})
Expand Down Expand Up @@ -19695,7 +19698,14 @@ function ProjectBoardRows({ view, onOpen }) {
}
},
(0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)),
(0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
(0, import_react46.createElement)(
"span",
{ className: "bt-project-title-wrap" },
(0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
// The audit text the row already carries (#1730): shown inside the card detail, never a
// second read. A row without one renders exactly as before.
row.audit === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-audit" }, row.audit)
),
row.priority === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-priority" }, row.priority),
row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
);
Expand Down
9 changes: 8 additions & 1 deletion core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const PROJECT_BOARD_CSS = `
.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
.bt-project-row[data-column="done"]{opacity:.7}
.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
.bt-project-empty{padding:22px 4px;opacity:.7}
.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
Expand Down Expand Up @@ -64,6 +66,7 @@ export function projectBoardView({ project, rows, canManage, tasks } = {}) {
nodeId: text(row.nodeId) || null,
title: text(task?.title) || text(task?.note) || null,
priority: text(task?.priority) || null,
audit: text(row.audit) || null,
board: boardOfRow(row, task),
}
}),
Expand Down Expand Up @@ -99,7 +102,11 @@ function ProjectBoardRows({ view, onOpen }) {
onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() },
},
h('span', { className: 'bt-project-column' }, t(row.column)),
h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')),
h('span', { className: 'bt-project-title-wrap' },
h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')),
// The audit text the row already carries (#1730): shown inside the card detail, never a
// second read. A row without one renders exactly as before.
row.audit === null ? null : h('span', { className: 'bt-project-audit' }, row.audit)),
row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority),
row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`))
})))
Expand Down
5 changes: 5 additions & 0 deletions core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function install(rt) {
: runProject?.id === id ? 'workflow' : null
if (source === null) continue
const fromRun = ref !== undefined && (runProject === null || runProject.id === id)
const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : ''
rows.push({
id: task.id,
source,
Expand All @@ -68,6 +69,10 @@ export function install(rt) {
...(task.handoff && typeof task.handoff === 'object' ? { handoff: task.handoff } : {}),
...(Array.isArray(task.evidence) ? { evidence: task.evidence } : {}),
...(task.lease && typeof task.lease === 'object' ? { lease: task.lease } : {}),
// The same projection the task store renders (#1730): one text block a card detail can
// show, with nothing written back. A pre-slice card answers the empty string and the row
// carries no `audit` key at all.
...(audit !== '' ? { audit } : {}),
...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}),
})
}
Expand Down
56 changes: 53 additions & 3 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,14 @@ export function install(rt) {
const runs = {}
const wanted = oneLine(query?.role ?? '', ROLE_MAX)
const wantedRun = query?.run === undefined || query?.run === null ? '' : String(query.run)
for (const task of Object.values(store())) {
const all = Object.values(store())
// A rework chain is one accepted delivery (#1730): an accepted parent and the `rework` child
// that resolved it are two records of the same delivery. The child still counts in
// `accepted`; `deliveries` counts the chain once, at its accepted parent. A child whose
// parent never landed is a delivery of its own — there is no chain to fold it into.
const acceptedIds = new Set(all.filter(task => task?.status === 'accepted').map(task => task.id))
const countsAsDelivery = task => task.taskClass !== 'rework' || !acceptedIds.has(task.reworkOf)
for (const task of all) {
const role = roleOf(task)
const run = String(task?.origin?.runId ?? task?.runId ?? '')
if (wanted !== '' && role !== wanted) continue
Expand All @@ -530,18 +537,61 @@ export function install(rt) {
const row = bucket(roles, role || 'unassigned')
if (row[task.status] !== undefined) row[task.status] += 1
if (task.status === 'accepted') {
row.deliveries += 1
if (countsAsDelivery(task)) row.deliveries += 1
const lease = leaseOf(task)
if (lease !== undefined && Number.isFinite(lease.takenAt)) row.workerSeconds += Math.max(0, Math.round((task.updatedAt - lease.takenAt) / 1000))
}
if (run !== '') {
const runRow = bucket(runs, run)
if (runRow[task.status] !== undefined) runRow[task.status] += 1
if (task.status === 'accepted') runRow.deliveries += 1
if (task.status === 'accepted' && countsAsDelivery(task)) runRow.deliveries += 1
}
}
return { roles, runs }
}
// One audit block from the typed rows (#1730). A projection, not a store: every line is read
// from fields the write path already validated, so rendering the same rows twice is the same
// text. A record stored before the slice carries none of the fields and answers the empty
// string — an audit of nothing is not an error.
const isoStamp = at => (Number.isFinite(at) ? new Date(at).toISOString() : '')
const auditLine = (task) => {
const lines = []
const handoff = task.handoff !== null && typeof task.handoff === 'object' ? task.handoff : undefined
if (handoff !== undefined && handoff.from && handoff.to) {
const reason = typeof handoff.reason === 'string' && handoff.reason !== '' ? `: ${handoff.reason}` : ''
lines.push(`handoff ${handoff.from} -> ${handoff.to}${reason} @ ${isoStamp(handoff.at)}`)
}
if (Array.isArray(task.evidence)) {
for (const entry of task.evidence) {
if (entry === null || typeof entry !== 'object') continue
const parts = []
if (entry.sha) parts.push(String(entry.sha))
if (typeof entry.pr === 'number') parts.push(`pr #${entry.pr}`)
if (entry.check) parts.push(String(entry.check))
const at = isoStamp(entry.at)
if (at !== '') parts.push(`@ ${at}`)
if (parts.length > 0) lines.push(`evidence ${parts.join(' ')}`)
}
}
const chain = []
const seen = new Set()
let cursor = task
while (cursor !== undefined && cursor !== null && !seen.has(cursor.id)) {
seen.add(cursor.id)
chain.push(cursor.id)
cursor = typeof cursor.reworkOf === 'string' && cursor.reworkOf !== '' ? find(cursor.reworkOf) : undefined
}
if (chain.length > 1) lines.push(`rework chain ${chain.reverse().join(' -> ')}`)
const lease = leaseOf(task)
if (lease !== undefined) {
lines.push(`lease ${holderName(lease.holder)} @ ${isoStamp(lease.takenAt)} -> ${isoStamp(lease.expiresAt)}`)
}
return lines
}
rt.taskAudit = function taskAudit(id) {
const task = find(String(id ?? ''))
return task === undefined ? '' : auditLine(task).join('\n')
}
// The workflow plugin reads a card's lease through the host (#1729), never a copy of its own.
rt.taskLease = function taskLease(id) {
const task = find(String(id ?? ''))
Expand Down
159 changes: 159 additions & 0 deletions core/test/task-audit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// The audit projection and the rework delivery count (#1730). The audit text is read from the
// typed rows already on the card — handoff, evidence, reworkOf, lease — and rendered twice from
// the same rows identically. A pre-slice record answers the empty string, not a throw. A rework
// chain counts one accepted delivery across two records and a replay cannot double it.

// The typed Task/Handoff contract (#1720): five additive fields on the one task record —
// `taskClass`, `reworkOf`, `handoff`, `evidence`, `lease` — validated on write and read by the
// Project board from the same row. The rework child is a card linked by `reworkOf` (never
// `parentId`), a lease returns an abandoned queued card to the queue, and a record stored before
// the slice still reads back and renders.
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime } from '../index.js'
import { fakeContext } from './fake-ctx.js'

const team = () => ({
version: 2,
revision: 1,
mainBotIds: ['a'],
members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } },
bots: {
a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' },
d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' },
d2: { id: 'd2', name: 'Dev2', role: 'dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' },
r1: { id: 'r1', name: 'Rev1', role: 'Reviewer', sessionId: 'r1', createdAt: 4, ownerId: 'owner-1', createdBy: 'a' },
},
rooms: {
war: {
id: 'war', name: 'War', members: ['a', 'd1', 'd2', 'r1'], admin: 'a', mode: 'everyone', named: true,
sessions: {}, log: [], seq: 0, seen: {},
participants: [
{ kind: 'human', id: 'owner-1', role: 'member', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'd1', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'd2', role: 'member', displayName: 'Dev2', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'r1', role: 'member', displayName: 'Rev1', joinedSeq: 0, joinedAt: 1 },
],
},
},
grants: {},
agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {},
})

async function boot(home) {
const ctx = fakeContext()
const agents = new Map()
const agentOf = (sessionId) => {
if (!agents.has(sessionId)) {
const inbox = {
nextStep: [], nextTurn: [],
remove(id) { const keep = message => message.id !== id; this.nextTurn = this.nextTurn.filter(keep); this.nextStep = this.nextStep.filter(keep); return true },
}
agents.set(sessionId, {
session: { id: sessionId }, id: sessionId, calls: [], status: 'idle', inbox,
followup(message) { this.calls.push(message); this.inbox.nextTurn.push(message) },
steer() {}, inject() {}, cancel() {}, async whenIdle() {},
})
}
return agents.get(sessionId)
}
Object.assign(ctx, {
llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
sessionController: { create: async () => ({ sessionId: 'made-1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) },
sessionTitle: { rename: async () => {} },
sessions: { flush: async () => {} },
})
const rt = createRuntime(ctx, { home })
await rt.load()
const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } })
const idOf = (body, title) => {
const line = body.split('\n').find(item => item.startsWith('- ') && item.includes(title))
const id = line?.match(/task-[0-9a-f]+/)?.[0]
assert.ok(id, body)
return id
}
const create = async (spec = {}) => {
const body = await track(spec.bot ?? 'd1', { tasks: [{ group: 'War', ...spec }] })
return { body, id: idOf(body, spec.title) }
}
return { ctx, rt, track, idOf, create, home, agents }
}

async function start(t, seed = team()) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-contract-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(seed))
const app = await boot(home)
app.stop = async () => rmSync(home, { recursive: true, force: true, maxRetries: 5 })
t.after(async () => {
try { await app.ctx.dispose() } catch {}
await app.stop()
})
return app
}


test('the audit renders one identical text block from the same rows (#1730)', async (t) => {
const app = await start(t)
const { id } = await app.create({
title: 'Audit the rework',
handoff: { from: 'Lead', to: 'Dev', at: 1_700_000_000_000, reason: 'needs the test' },
})
app.rt.state.tasks[id].evidence = [
{ sha: 'a'.repeat(40), pr: 1730, check: 'tests-ok', at: 1_700_000_100_000 },
]
const child = app.rt.state.tasks[id]
const audit = app.rt.taskAudit(id)
assert.match(audit, /^handoff Lead -> Dev: needs the test @ 2023-11-14T22:13:20.000Z$/m)
assert.match(audit, /^evidence a{40} pr #1730 tests-ok @ 2023-11-14T22:15:00.000Z$/m)
assert.equal(audit, app.rt.taskAudit(id), 'the same rows render the same text')
assert.equal(audit, app.rt.taskAudit(child.id), 'the store is read, not consumed')
})

test('the audit names the rework chain and the current lease window (#1730)', async (t) => {
const app = await start(t)
const { id } = await app.create({ title: 'Chain me' })
app.rt.state.tasks[id].lease = { holder: 'd1', takenAt: 1_700_000_000_000, expiresAt: 1_700_001_800_000 }
const childId = 'task-rework1'
app.rt.state.tasks[childId] = {
id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id,
ownerBotId: 'd1', createdAt: 2, updatedAt: 2,
}
const audit = app.rt.taskAudit(childId)
assert.match(audit, new RegExp('^rework chain ' + id + ' -> ' + childId + '$', 'm'))
assert.match(app.rt.taskAudit(id), /^lease Dev1 @ 2023-11-14T22:13:20.000Z -> 2023-11-14T22:43:20.000Z$/m)
})

test('a pre-slice record renders an empty audit, not a throw (#1730)', async (t) => {
const app = await start(t)
const { id } = await app.create({ title: 'Old card' })
delete app.rt.state.tasks[id].taskClass
assert.equal(app.rt.taskAudit(id), '')
assert.equal(app.rt.taskAudit('task-nope'), '')
})

test('a rework chain counts one accepted delivery and a replay doubles nothing (#1730)', async (t) => {
const app = await start(t)
const { id } = await app.create({ title: 'Ship once' })
const childId = 'task-rework2'
app.rt.state.tasks[id].status = 'accepted'
app.rt.state.tasks[id].ownerBotId = 'd1'
app.rt.state.tasks[childId] = {
id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id,
ownerBotId: 'd1', createdAt: 2, updatedAt: 2,
}
const first = app.rt.taskCounters()
assert.equal(first.roles.Dev.accepted, 2, 'two records')
assert.equal(first.roles.Dev.deliveries, 1, 'one accepted delivery for the chain')
// A replay writes the same rows again; the count is a read, so it cannot double.
app.rt.state.tasks[id].status = 'accepted'
app.rt.state.tasks[childId].status = 'accepted'
assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1)
// A rework whose parent never landed is still a delivery of its own.
delete app.rt.state.tasks[id]
assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1)
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ by `source` (`bot` | `group` | `workflow`, empty sources left out), carries `col
`runId`/`nodeId` a workflow row came from, and keeps `row.id` equal to the underlying task id, so a row
never copies a row. The `tasks` list is the ordinary task read joined by `id` (a `Map`, one pass): a row
takes its `title`, falls back to `note`, and only then to `@assignee` / `—`, plus its `priority`, so three
cards held by the same person no longer read the same. An empty Project renders the empty state, not an
cards held by the same person no longer read the same. A row that carries the host's audit text (#1730) shows it inside the card detail as `bt-project-audit`, read from that same row with no second fetch; a row without it renders exactly as before. An empty Project renders the empty state, not an
error. `canManage` comes from `project-read` and only decides whether the management affordances are
drawn; the refusal itself is the host's.

Expand Down
Loading
Loading