feat: make heartbeat trigger incremental reviews and suggest from graph changes
Heartbeat no longer writes its own report; it triggers the shared incremental review and, per plan intervention, derives evidence-backed suggestions from the review's graph changes. Manual review is incremental by default with explicit reanalysis. The Supervisor tab is renamed Smart heartbeat with heartbeat plans, and the redundant inner plan frame is removed.
This commit is contained in:
@@ -89,11 +89,11 @@ void app.whenReady().then(async () => {
|
||||
fail: (id, error) => db.failSupervisionRun(id, error), noChange: (id) => db.noChangeSupervisionRun(id),
|
||||
candidates: async (input) => db.listSupervisionCandidates(input), save: async (result) => db.saveSupervisionResult(result)
|
||||
})
|
||||
const heartbeat = new HeartbeatService(db, { summarize: async (input) => {
|
||||
stage = 'heartbeat'
|
||||
return call([input.systemInstruction, 'OUTPUT CONTRACT:', JSON.stringify(input.outputContract),
|
||||
'BOUNDED INPUT:', JSON.stringify(input.input), 'Return only JSON. Do not propose memories or follow-up tasks for this simple release update.'].join('\n\n'))
|
||||
} }, () => { throw new Error('Tools denied') }, async ({ run }) => { await supervisor.run(request, run.id) })
|
||||
// The heartbeat reads no sources itself; it only triggers the shared review.
|
||||
const heartbeat = new HeartbeatService(db, { review: async ({ run }) => {
|
||||
const result = await supervisor.run(request, run.id)
|
||||
return { status: result.status ?? 'completed', runId: result.runId }
|
||||
} })
|
||||
const config = heartbeat.create({ name: 'Isolated live test', scope: request.scope, timezone: 'UTC',
|
||||
recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }, now)
|
||||
let tick = 0
|
||||
@@ -102,23 +102,23 @@ void app.whenReady().then(async () => {
|
||||
await heartbeat.processDue(new Date())
|
||||
}
|
||||
await due()
|
||||
assert.equal(observations.length, 2)
|
||||
assert.equal(observations.length, 1, 'A heartbeat must make exactly one review request')
|
||||
assert.equal(db.listSupervisionActivity()[0]!.status, 'completed')
|
||||
const first = db.listSupervisionCandidates(request)
|
||||
assert(first.length > 0, 'Model did not create an entity')
|
||||
const firstGraph = db.getSupervisionGraph()
|
||||
await due()
|
||||
assert.equal(observations.length, 2, 'Unchanged automatic run made a request')
|
||||
assert.equal(observations.length, 1, 'Unchanged automatic run made a request')
|
||||
assert.equal(db.listSupervisionActivity()[0]!.status, 'no_change')
|
||||
assert.deepEqual(db.getSupervisionGraph(), firstGraph)
|
||||
sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run(
|
||||
'The same Atlas release has moved from October 4 to October 6. SQLite remains the storage choice. Update the existing Atlas release entity.', messageId)
|
||||
const changed = await supervisor.run(request)
|
||||
assert.equal(observations.length, 3)
|
||||
assert.equal(observations.length, 2)
|
||||
const reused = changed.output.entities.filter((entity) => entity.persistedId && first.some((candidate) => candidate.id === entity.persistedId))
|
||||
assert(reused.length > 0, 'Changed review did not reuse a known entity ID')
|
||||
assert.equal((await supervisor.run(request)).status, 'no_change')
|
||||
assert.equal(observations.length, 3)
|
||||
assert.equal(observations.length, 2)
|
||||
assert.deepEqual(sql.prepare('PRAGMA foreign_key_check').all(), [])
|
||||
assert(readFileSync(settingsPath).equals(original), 'Source settings changed')
|
||||
console.log(JSON.stringify({ model: settings.modelName, protocol: settings.modelProtocol, actualCalls: observations,
|
||||
|
||||
@@ -243,7 +243,7 @@ app
|
||||
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: panel.clientWidth, scrollWidth: panel.scrollWidth,
|
||||
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
||||
suggestions: !!panel.querySelector('#heartbeat-panel-suggestions'),
|
||||
suggestions: !!panel.querySelector('#supervision-suggestions-title'),
|
||||
refresh: [...panel.querySelectorAll('button')].filter(b => b.getClientRects().length && b.textContent.includes('刷新')).length,
|
||||
recap: !!recap && !!recap.getClientRects().length,
|
||||
paragraphs: recap?.querySelectorAll('.supervisor-workspace__summary').length ?? 0,
|
||||
@@ -256,21 +256,21 @@ app
|
||||
assert.equal(report.suggestions, tab === 'plans' && scenario === 'populated')
|
||||
assert.equal(report.recap, tab === 'overview' && scenario !== 'empty')
|
||||
if (tab === 'overview') {
|
||||
assert(!report.text.includes('暂无自动监督报告') && !report.text.includes('待确认记忆'))
|
||||
assert(!report.text.includes('暂无历史心跳报告') && !report.text.includes('待确认记忆') && !report.text.includes('监督建议'))
|
||||
if (scenario !== 'empty') {
|
||||
assert.equal(report.paragraphs, 4)
|
||||
assert(report.text.includes('外部评审时间') && report.text.includes('未解决事项'))
|
||||
assert(report.clientWidth - report.proseWidth <= 50, 'Prose fills the panel within card padding')
|
||||
} else assert(report.text.includes('还没有成功回顾'))
|
||||
} else {
|
||||
assert(!report.text.includes('暂无自动监督报告'))
|
||||
assert.equal(report.text.includes('自动监督报告'), scenario === 'populated')
|
||||
assert(!report.text.includes('暂无历史心跳报告'))
|
||||
assert.equal(report.text.includes('历史心跳报告'), scenario === 'populated')
|
||||
}
|
||||
reports.push({ scenario, tab, ...report })
|
||||
const prefix = `recap-${scenario}-${tab}-${width}`
|
||||
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
if (tab === 'plans' && scenario === 'populated') {
|
||||
for (const anchor of ['heartbeat-memory-title', 'heartbeat-reports-title', 'heartbeat-runs-title']) {
|
||||
for (const anchor of ['supervision-suggestions-title', 'heartbeat-reports-title']) {
|
||||
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
|
||||
if (anchor === 'heartbeat-reports-title' && scenario === 'populated') await js(`document.querySelector('#heartbeat-panel-history button[aria-expanded=false]')?.click()`)
|
||||
await settle()
|
||||
@@ -281,6 +281,35 @@ app
|
||||
}
|
||||
if (scenario === 'populated') {
|
||||
await js('document.querySelector("#supervisor-tab-overview").click()')
|
||||
// Toolbar: incremental review button, refresh, and the more menu holding re-analysis.
|
||||
const toolbar = await js(`(() => {
|
||||
const bar = document.querySelector('.supervisor-workspace__toolbar');
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const more = bar.querySelector('[aria-haspopup=menu]');
|
||||
// Rows are groups of items whose vertical ranges overlap.
|
||||
const items = [...bar.children].filter(el => el.getClientRects().length).map(el => el.getBoundingClientRect()).sort((a, b) => a.top - b.top);
|
||||
const tops = items.reduce((rows, rect) => { const last = rows.at(-1); if (last && rect.top < last.bottom) last.bottom = Math.max(last.bottom, rect.bottom); else rows.push({ top: Math.round(rect.top), bottom: rect.bottom }); return rows }, []).map(row => row.top);
|
||||
return { primary: bar.querySelector('.primary-button').textContent, more: more?.getAttribute('aria-label'),
|
||||
overflow: bar.scrollWidth > rect.width + 1, rows: new Set(tops).size };
|
||||
})()`)
|
||||
assert.equal(toolbar.primary, '回顾')
|
||||
assert.equal(toolbar.more, '更多回顾操作')
|
||||
assert.equal(toolbar.overflow, false, 'Recap toolbar overflows')
|
||||
assert(toolbar.rows <= 2, `Recap toolbar wraps into ${toolbar.rows} rows`)
|
||||
await js('document.querySelector(".supervisor-workspace__toolbar [aria-haspopup=menu]").click()')
|
||||
await wait('!!document.querySelector("[role=menu] [role=menuitem]")')
|
||||
assert.equal(await js('document.querySelector("[role=menu] [role=menuitem]").textContent'), '重新整理…')
|
||||
await js('document.querySelector("[role=menu] [role=menuitem]").click()')
|
||||
await wait('!!document.querySelector(".supervisor-workspace__confirm")')
|
||||
const confirm = await js(`(() => { const el = document.querySelector('.supervisor-workspace__confirm'); const r = el.getBoundingClientRect();
|
||||
return { width: r.width, overflow: el.scrollWidth > el.clientWidth + 1, text: el.textContent, buttons: [...el.querySelectorAll('button')].map(b => b.textContent) } })()`)
|
||||
assert.equal(confirm.overflow, false, 'Re-analysis confirmation overflows')
|
||||
assert.deepEqual(confirm.buttons, ['取消', '重新整理'])
|
||||
assert(confirm.text.includes('可能产生较多模型用量'))
|
||||
await settle()
|
||||
await writeFile(join(artifacts, `recap-reanalyze-confirm-${win.getContentSize()[0]}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
await js('document.querySelector(".supervisor-workspace__confirm .secondary-button").click()')
|
||||
await wait('!document.querySelector(".supervisor-workspace__confirm")')
|
||||
await js(`(() => { const s = document.querySelector('.supervisor-workspace__result-navigation select'); s.value = 'older-result'; s.dispatchEvent(new Event('change', {bubbles:true})); })()`)
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click(); document.querySelector(".page-shell").scrollTop = 0')
|
||||
@@ -321,16 +350,20 @@ app
|
||||
trend: !!panel.querySelector('#heartbeat-trend-title'),
|
||||
audit: !!panel.querySelector('#heartbeat-runs-title'),
|
||||
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
||||
suggestions: !!panel.querySelector('#heartbeat-panel-suggestions'),
|
||||
emptyReports: panel.textContent.includes('暂无自动监督报告'),
|
||||
suggestions: !!panel.querySelector('#supervision-suggestions-title'),
|
||||
suggestionItems: panel.querySelectorAll('.supervision-suggestions__list > li').length,
|
||||
emptyReports: panel.textContent.includes('暂无历史心跳报告'),
|
||||
plans: !!panel.querySelector('#heartbeat-panel-plans'),
|
||||
create: [...panel.querySelectorAll('button')].filter(b => b.textContent === '创建自动监督计划').length,
|
||||
create: [...panel.querySelectorAll('button')].filter(b => b.textContent === '创建心跳计划').length,
|
||||
emptyStates: panel.querySelectorAll('#heartbeat-panel-overview .empty-state').length,
|
||||
activity: panel.querySelectorAll('.supervisor-activity__item').length };
|
||||
})()`)
|
||||
assert(report.pageWidth <= width && report.scrollWidth <= report.panelWidth, 'Automatic panel overflow')
|
||||
for (const key of ['overview', 'plans']) assert.equal(report[key], tab === 'plans', key)
|
||||
for (const key of ['metrics', 'trend', 'audit', 'reports', 'suggestions']) assert.equal(report[key], tab === 'plans' && !empty, key)
|
||||
// Statistics, the trend and the run audit are gone; earlier reports stay readable.
|
||||
for (const key of ['metrics', 'trend', 'audit']) assert.equal(report[key], false, key)
|
||||
for (const key of ['reports', 'suggestions']) assert.equal(report[key], tab === 'plans' && !empty, key)
|
||||
assert.equal(report.suggestionItems, tab === 'plans' && !empty ? 2 : 0)
|
||||
assert.equal(report.emptyReports, false)
|
||||
assert.equal(report.create, tab === 'plans' ? 1 : 0)
|
||||
assert.equal(report.emptyStates, 0)
|
||||
@@ -339,15 +372,27 @@ app
|
||||
const prefix = `automatic-${scenario}-${tab}-light-${width}`
|
||||
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
if (tab === 'plans') {
|
||||
for (const anchor of empty ? [] : ['heartbeat-trend-title', 'heartbeat-runs-title']) {
|
||||
for (const anchor of empty ? [] : ['supervision-suggestions-title', 'heartbeat-reports-title']) {
|
||||
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
|
||||
await settle()
|
||||
await writeFile(join(artifacts, `${prefix}-${anchor}.png`), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
await js('document.querySelector(".heartbeat-settings__intro button").click()')
|
||||
await wait('!!document.querySelector("[role=dialog]")')
|
||||
assert(await js('document.querySelector("[role=dialog] > header button").getAttribute("aria-label") === "关闭自动监督计划"'))
|
||||
assert(await js('document.querySelector("[role=dialog] > header button").getAttribute("aria-label") === "关闭心跳计划"'))
|
||||
assert.equal(await js('document.querySelector("[role=dialog] > footer").textContent'), '取消保存并启用计划')
|
||||
const intervention = await js(`(() => {
|
||||
const dialog = document.querySelector('[role=dialog]');
|
||||
const group = [...dialog.querySelectorAll('fieldset')].find(f => f.textContent.includes('介入方式'));
|
||||
const buttons = group ? [...group.querySelectorAll('button')].filter(b => b.textContent === '生成建议' || b.textContent === '仅更新记忆') : [];
|
||||
const body = dialog.querySelector('.custom-task-dialog__body, [class*=body]') || dialog;
|
||||
return { present: !!group, options: buttons.map(b => b.textContent), pressed: buttons.find(b => b.getAttribute('aria-pressed') === 'true' || b.getAttribute('aria-checked') === 'true')?.textContent,
|
||||
overflow: body.scrollWidth > body.clientWidth + 1 };
|
||||
})()`)
|
||||
assert.equal(intervention.present, true, 'Intervention field missing')
|
||||
assert.deepEqual(intervention.options, ['生成建议', '仅更新记忆'])
|
||||
assert.equal(intervention.overflow, false, 'Plan modal overflows horizontally')
|
||||
if (width === 390) await writeFile(join(artifacts, `plan-modal-intervention-${scenario}-390.png`), (await win.webContents.capturePage()).toPNG())
|
||||
await js('document.querySelector("[role=dialog] > header button").click()')
|
||||
await wait('!document.querySelector("[role=dialog]")')
|
||||
}
|
||||
@@ -503,7 +548,7 @@ app
|
||||
const before = await measure()
|
||||
assert(before.pageWidth <= width && before.bodyScrollWidth <= before.bodyWidth, 'No horizontal overflow')
|
||||
assert(before.dialog.top >= 0 && before.dialog.bottom <= height, 'Dialog fits viewport')
|
||||
assert(before.headerButtons === 1 && before.closeLabel === '关闭自动监督计划' && before.tooltip === before.closeLabel, 'Accessible header X only')
|
||||
assert(before.headerButtons === 1 && before.closeLabel === '关闭心跳计划' && before.tooltip === before.closeLabel, 'Accessible header X only')
|
||||
assert(before.title.right <= before.close.left && before.close.right > (before.dialog.left + before.dialog.right) / 2, 'Title left, close right')
|
||||
assert.equal(before.footerAlign, 'flex-end')
|
||||
assert.equal(before.text[0], '取消')
|
||||
@@ -1039,7 +1084,7 @@ app
|
||||
headings: [...panel.querySelectorAll('h2')].map(e => e.textContent),
|
||||
refresh: [...panel.querySelectorAll('button')].some(e => e.textContent.includes('刷新')) };
|
||||
})()`)
|
||||
assert.deepEqual(menuLayout.tabs, ['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
|
||||
assert.deepEqual(menuLayout.tabs, ['工作回顾', '故事线图谱', '智能心跳', '活动记录', '设置'])
|
||||
assert.equal(menuLayout.nestedMenus, tab === 'graph' ? 1 : 0)
|
||||
assert(menuLayout.pageWidth <= width && menuLayout.scrollWidth <= menuLayout.panelWidth, 'Menu panel overflow')
|
||||
assert.equal(menuLayout.reports, tab === 'plans')
|
||||
|
||||
@@ -140,6 +140,8 @@ if (params.has('short')) {
|
||||
graph.eventEntities = graph.eventEntities.slice(0, 1)
|
||||
graph.eventSources = graph.eventSources.slice(0, 1)
|
||||
}
|
||||
const menu = params.has('menu')
|
||||
const createdAt = '2026-09-23T08:00:00.000Z'
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
value:
|
||||
state === 'unavailable'
|
||||
@@ -205,6 +207,16 @@ Object.defineProperty(window, 'goodbuddy', {
|
||||
},
|
||||
entityAction: async () => {},
|
||||
relationAction: async () => {},
|
||||
suggestions: async () => menu ? [
|
||||
{ id: '00000000-0000-4000-8000-000000000901', resultId: 'fixture-result', heartbeatRunId: 'run', scope: { kind: 'global' }, kind: 'open_item',
|
||||
title: '模拟建议:确认外部评审时间', detail: '外部评审时间仍未确定,交付清单需要回填。下次回顾前可先确认这一项。', sourceIds: ['source-1', 'source-2'],
|
||||
entityId: null, relationId: null, taskId: null, status: 'pending', createdAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000902', resultId: 'fixture-result', heartbeatRunId: 'run', scope: { kind: 'global' }, kind: 'conflict',
|
||||
title: '模拟建议:两种交付顺序尚未统一', detail: '一处记录先核验资料,另一处先确认日期,尚无明确取舍。', sourceIds: ['source-1'],
|
||||
entityId: null, relationId: null, taskId: null, status: 'pending', createdAt }
|
||||
] : [],
|
||||
suggestionAction: async () => { throw new Error('Preview fixture must not change suggestions') },
|
||||
retrySuggestions: async () => 0,
|
||||
source: async () => ({
|
||||
...(params.has('preview') ? { sourceType: 'conversation', sourceId: 'simulated-conversation-uuid' } : {}),
|
||||
title: '模拟会议记录',
|
||||
@@ -215,8 +227,6 @@ Object.defineProperty(window, 'goodbuddy', {
|
||||
}
|
||||
})
|
||||
const noop = async () => {}
|
||||
const menu = params.has('menu')
|
||||
const createdAt = '2026-09-23T08:00:00.000Z'
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<UiLocaleProvider initialPreference="zh-CN">
|
||||
<div
|
||||
@@ -259,7 +269,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
onUpdateApplicationSettings={async () => true}
|
||||
configs={menu || params.has('plan-only') ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
|
||||
runs={menu ? [{ id: 'run', configId: 'plan', trigger: 'scheduled', scheduledFor: createdAt, status: 'completed', attemptCount: 1, createdAt, updatedAt: createdAt }] : []}
|
||||
entries={menu ? [{ id: 'report', configId: 'plan', runId: 'run', scheduledFor: createdAt, summary: '模拟自动监督报告:交付计划已更新,待核对负责人和验收日期。', highlights: ['保留原始依据,核对交付时间。'], proposedMemoryIds: ['memory'], followUpTaskIds: ['task'], createdAt }] : []}
|
||||
entries={menu ? [{ id: 'report', configId: 'plan', runId: 'run', scheduledFor: createdAt, summary: '模拟历史心跳报告:交付计划已更新,待核对负责人和验收日期。', highlights: ['保留原始依据,核对交付时间。'], proposedMemoryIds: ['memory'], followUpTaskIds: ['task'], createdAt }] : []}
|
||||
memories={menu ? [{ id: 'memory', scope: 'global', type: 'preference', content: '模拟建议:周会总结保留负责人和下一次检查日期。', confidence: 0.9, salience: 0.8, status: 'proposed', createdAt, updatedAt: createdAt }] : []}
|
||||
projects={[]}
|
||||
tasks={menu ? [{ id: 'task', title: '模拟行动:核对交付清单', instructions: '核对负责人、验收日期和原始讨论依据。', origin: 'assistant', status: 'paused', createdAt }] : []}
|
||||
|
||||
Reference in New Issue
Block a user