diff --git a/changelog.d/1489-workflow-timeline-locale.fixed.md b/changelog.d/1489-workflow-timeline-locale.fixed.md index 1e9b90a09..73c0d99c2 100644 --- a/changelog.d/1489-workflow-timeline-locale.fixed.md +++ b/changelog.d/1489-workflow-timeline-locale.fixed.md @@ -1 +1 @@ -Workflow stage timeline shows step badges and event text in English when the UI locale is English. ([#1489](https://github.com/ThinkFlowLab/FlyBot/issues/1489)) +The Workflow stage timeline now follows the shell language — task assigned, queued, workflow created, paused, resumed, stopped, the failure, hand-off and hand-off-retry lines, the deadline timeout, the retry and recovery frames, the parallel start/join and done frames, reset, and the canned Delivery replies read English in an English shell and Chinese in a Chinese one; each row keeps its Chinese text as the fallback for timelines written before the key existed. Every timestamp is computed from the line's own time, so Done and historical runs show their real age instead of a frozen 「刚刚」, and the stamp is 12px on the readable ink token. ([#1489](https://github.com/ThinkFlowLab/FlyBot/issues/1489)) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1d3fa976d..712006a8a 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -118,6 +118,41 @@ window.__ModuleLoader__.load({ 'Next run: {time}': '下次运行:{time}', 'Step {step}': '步骤 {step}', 'Parallel branches · Merge after all pass': '并行分支 · 全部通过后汇合', + // Stage timeline (#1489). The key is the English line; service.js and + // narrate.js keep the Chinese text only as the fallback for rows written + // before the key existed. + 'Task assigned · {task} → {workflow}': '任务已分配 · {task} → {workflow}', + 'Queued · {task} ({ahead} ahead)': '排队 · {task}(前面 {ahead} 个)', + 'Workflow "{name}" created · members {members}': 'Workflow「{name}」已创建 · 成员 {members}', + '⏸ Paused · {task}': '⏸ 已暂停 · {task}', + '▶ Resumed · {task}': '▶ 已继续 · {task}', + '■ Stopped · {task}': '■ 已停止 · {task}', + '{node} cannot start: missing input {missing}.': '{node} 不能开始:缺少输入 {missing}。', + '{node} did not take this step ({code}).': '{node} 没有接到这一步({code})。', + '{node} did not take this step ({code}); retrying in {seconds}s ({tries}/{total}).': '{node} 没有接到这一步({code}),{seconds} 秒后重试({tries}/{total})。', + '{node} did not take this step ({code}); {total} retries did not land, the run failed.': '{node} 没有接到这一步({code}),重试 {total} 次仍未落地,run 已标记失败。', + '{node} timed out: no reply for {minutes} minutes, the run was marked failed.': '{node} 超时:{minutes} 分钟没有回包,run 已标记失败。', + 'Started · {task} · {nodes}': '开始 · {task} · {nodes}', + 'Task {task} entered the workflow; {nodes} claimed it': '任务 {task} 已进入 Workflow,{nodes} 认领', + '{node} failed; went back to {nodes} to retry': '{node} 失败,已返回 {nodes} 重试', + '↺ Recovered from failure → {nodes}': '↺ 失败恢复 → {nodes}', + '{node} failed this step and stops here.': '{node} 这一步失败了,停在本节点。', + 'Snapshot test notice.snap does not match (old copy snapshot not updated). Retried 1/2 automatically, and Coder was asked to update the snapshot.': '快照测试 notice.snap 不一致(旧文案快照未更新)。自动重试 1/2,并已通知 Coder 更新快照。', + '↻ {node} retried after a failure': '↻ {node} 失败后重试', + '↻ {node} retried automatically after a failure': '↻ {node} 失败后自动重试', + '↻ Tester retried automatically after a failure · Coder pushed the snapshot update': '↻ Tester 失败后自动重试 · Coder 已推送快照更新', + '{node} finished this step.': '{node} 完成了这一步。', + '∥ Parallel branches started: {nodes}': '∥ 并行分支开始:{nodes}', + '⇢ Parallel branches joined (join) → {nodes}': '⇢ 并行分支汇合(join)→ {nodes}', + '✓ Done · {task} · {steps} steps': '✓ 完成 · {task} · {steps} 步', + '↺ Run reset': '↺ 已重置运行', + 'Broke down "{task}": Writer gets the notice under 2 lines, conclusion first; Coder replaces the copy and the collapse logic; Reviewer and Tester verify in parallel.': '已拆解「{task}」:Writer 把 notice 压到 2 行以内,结论先行;Coder 替换文案与折叠逻辑;Reviewer 与 Tester 并行验收。', + 'Draft done (86 chars → 31): "The delivery team is set up; members sync automatically. Tap here for progress →"': '草稿完成(原 86 字 → 31 字):「交付组已建好,成员会自动同步。点此查看进度 →」', + 'Patch submitted: replaces the copy and collapses anything over 2 lines. 3 files changed.': '补丁已提交:替换文案,超过 2 行自动折叠。改动 3 个文件。', + 'Review passed. Suggestion: make the collapse threshold a constant, NOTICE_MAX_LINES.': 'Review 通过。建议把折叠阈值做成常量 NOTICE_MAX_LINES。', + 'Retry passed: unit 128/128, snapshots 24/24, E2E 6/6.': '重试通过:单元 128/128,快照 24/24,E2E 6/6。', + 'Merged and rolled out to 10% of traffic. Board card {id} moved to Done.': '已合并并灰度 10% 发布。看板卡片 {id} 已移到「已完成」。', + '{node} finished this step of "{task}".': '{node} 完成了「{task}」的这一步。', } function zhUi() { @@ -162,12 +197,27 @@ window.__ModuleLoader__.load({ } function tr(key, params) { const host = globalThis.__flybot__?.t - const fromHost = typeof host === 'function' ? host(key, params) : key - if (fromHost !== key) return fromHost - const text = zhUi() && Object.hasOwn(ZH, key) ? ZH[key] : key + // Ask the host for the key alone: it fills an unknown key's params itself, + // and that fill would hide the plugin's own copy from the fallback below. + const fromHost = typeof host === 'function' ? host(key) : key + const text = fromHost !== key ? fromHost : zhUi() && Object.hasOwn(ZH, key) ? ZH[key] : key return fill(text, params) } + // The stage clock at the ages the core's list rows use: "just now", "5 min", + // "3 h", "2 d", then a date. Read at render time, so a Done run ages; only a + // row written before the stamp existed still paints its stored text. + function relativeTime(at) { + const ms = Number(at) + if (!Number.isFinite(ms)) return '' + const diff = Math.max(0, Date.now() - ms) + if (diff < 60_000) return tr('just now') + if (diff < 3_600_000) return tr('{n} min', { n: Math.floor(diff / 60_000) }) + if (diff < 86_400_000) return tr('{n} h', { n: Math.floor(diff / 3_600_000) }) + if (diff < 30 * 86_400_000) return tr('{n} d', { n: Math.floor(diff / 86_400_000) }) + return new Date(ms).toLocaleDateString(zhUi() ? 'zh-CN' : 'en-US', { month: 'short', day: 'numeric' }) + } + const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', skipped: 'Skipped', idle: 'Idle' } const COLUMN_KEY = { 'not-started': 'Not started', 'in-progress': 'In progress', done: 'Done' } const COLUMN_OF = { open: 'not-started', claimed: 'not-started', working: 'in-progress', blocked: 'in-progress', 'result-ready': 'in-progress', accepted: 'done', cancelled: 'done', failed: 'done' } @@ -467,15 +517,19 @@ window.__ModuleLoader__.load({ } function Bubble({ message, workflow, roster }) { - if (message.kind === 'time') return h('div', { className: 'wf-ts' }, message.text) - if (message.kind === 'system') return h('div', { className: `wf-sys ${message.tone ?? ''}`, title: message.text }, message.text) + if (message.kind === 'time') return h('div', { className: 'wf-ts' }, relativeTime(message.at) || message.text) + if (message.kind === 'system') { + const text = message.key ? tr(message.key, message.params) : message.text + return h('div', { className: `wf-sys ${message.tone ?? ''}`, title: text }, text) + } if (message.kind === 'user') return h('div', { className: 'wf-user', title: message.text }, message.text) const node = nodeOf(workflow, message.botId) ?? { id: message.botId, name: message.botId } + const text = message.key ? tr(message.key, message.params) : message.text return h('div', { className: 'wf-msg' }, h(Mark, { node, roster, size: 28 }), h('div', null, - h('div', { className: 'wf-who' }, node.name, message.step ? h('span', { className: 'wf-step' }, tr('Step {step}', { step: message.step })) : null), - h('div', { className: 'wf-bub', title: message.text }, message.text, + h('div', { className: 'wf-who' }, node.name, message.step ? h('span', { className: 'wf-step' }, tr('Step {step}', { step: tr(message.step) })) : null), + h('div', { className: 'wf-bub', title: text }, text, message.attachments?.length ? h('div', { className: 'wf-att' }, message.attachments.map(name => h('span', { key: name }, name))) : null))) } @@ -1591,7 +1645,7 @@ window.__ModuleLoader__.load({ .wf-chip.failed{background:rgba(229,72,77,.12);color:#e5484d} .wf-chat{flex:1;overflow:auto;padding:10px 0 20px} .wf-chat-in{width:min(760px,calc(100% - 48px));margin:0 auto;display:flex;flex-direction:column;gap:10px} -.wf-ts{text-align:center;font-size:11px;color:var(--bt-ink-3,#9a9a9a)} +.wf-ts{text-align:center;font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6))} .wf-sys{align-self:center;font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));border:1px dashed var(--bt-line,rgba(20,20,20,.08));padding:4px 10px;border-radius:12px;max-width:90%;text-align:center;word-wrap:break-word;overflow-wrap:break-word} .wf-sys.accent{color:var(--bt-accent,#4d6bfe);background:rgba(77,107,254,.1);border-style:solid} .wf-sys.ok{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} diff --git a/plugins/workflow/src/narrate.js b/plugins/workflow/src/narrate.js index 980789203..33d276f11 100644 --- a/plugins/workflow/src/narrate.js +++ b/plugins/workflow/src/narrate.js @@ -1,6 +1,8 @@ // Chat lines for one graph step. Delivery-template nodes use canned copy when -// no Bot reply is quoted; any other graph gets a short line that names the Bot and the task. Text is -// plain: the client renders it as a text node. +// no Bot reply is quoted; any other graph gets a short line that names the Bot and the task. +// Every frame carries the English key and its params, so the client follows the +// shell language; the stored Chinese text stays as the fallback for rows written +// before the key existed (#1489). import { nodeById, stepLabel, summarizeTask } from './graph.js' import { DELIVERY_ID } from './template.js' @@ -9,17 +11,61 @@ function names(workflow, ids) { return ids.map(id => nodeById(workflow, id)?.name ?? id).join(' ‖ ') } -function doneText(workflow, nodeId, task) { +function frame(kind, key, params, text, extra = {}) { + return { kind, key, params, text, ...extra } +} + +function quoted(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim() +} + +function doneCopy(workflow, nodeId, task) { if (workflow.id === DELIVERY_ID) { - if (nodeId === 'planner') return `已拆解「${task.title}」:Writer 把 notice 压到 2 行以内,结论先行;Coder 替换文案与折叠逻辑;Reviewer 与 Tester 并行验收。` - if (nodeId === 'writer') return '草稿完成(原 86 字 → 31 字):「交付组已建好,成员会自动同步。点此查看进度 →」' - if (nodeId === 'coder') return '补丁已提交:替换文案,超过 2 行自动折叠。改动 3 个文件。' - if (nodeId === 'reviewer') return 'Review 通过。建议把折叠阈值做成常量 NOTICE_MAX_LINES。' - if (nodeId === 'tester') return '重试通过:单元 128/128,快照 24/24,E2E 6/6。' - if (nodeId === 'deployer') return `已合并并灰度 10% 发布。看板卡片 ${task.id} 已移到「已完成」。` + if (nodeId === 'planner') { + return { + key: 'Broke down "{task}": Writer gets the notice under 2 lines, conclusion first; Coder replaces the copy and the collapse logic; Reviewer and Tester verify in parallel.', + params: { task: task.title }, + text: `已拆解「${task.title}」:Writer 把 notice 压到 2 行以内,结论先行;Coder 替换文案与折叠逻辑;Reviewer 与 Tester 并行验收。`, + } + } + if (nodeId === 'writer') { + return { + key: 'Draft done (86 chars → 31): "The delivery team is set up; members sync automatically. Tap here for progress →"', + text: '草稿完成(原 86 字 → 31 字):「交付组已建好,成员会自动同步。点此查看进度 →」', + } + } + if (nodeId === 'coder') { + return { + key: 'Patch submitted: replaces the copy and collapses anything over 2 lines. 3 files changed.', + text: '补丁已提交:替换文案,超过 2 行自动折叠。改动 3 个文件。', + } + } + if (nodeId === 'reviewer') { + return { + key: 'Review passed. Suggestion: make the collapse threshold a constant, NOTICE_MAX_LINES.', + text: 'Review 通过。建议把折叠阈值做成常量 NOTICE_MAX_LINES。', + } + } + if (nodeId === 'tester') { + return { + key: 'Retry passed: unit 128/128, snapshots 24/24, E2E 6/6.', + text: '重试通过:单元 128/128,快照 24/24,E2E 6/6。', + } + } + if (nodeId === 'deployer') { + return { + key: 'Merged and rolled out to 10% of traffic. Board card {id} moved to Done.', + params: { id: task.id }, + text: `已合并并灰度 10% 发布。看板卡片 ${task.id} 已移到「已完成」。`, + } + } } const node = nodeById(workflow, nodeId) - return `${node?.name ?? nodeId} 完成了「${task.title}」的这一步。` + return { + key: '{node} finished this step of "{task}".', + params: { node: node?.name ?? nodeId, task: task.title }, + text: `${node?.name ?? nodeId} 完成了「${task.title}」的这一步。`, + } } function attachmentsOf(workflow, nodeId) { @@ -29,45 +75,62 @@ function attachmentsOf(workflow, nodeId) { return undefined } -function liveText(value, fallback) { - const text = String(value ?? '').replace(/\s+/g, ' ').trim() - return text || fallback +function completeLine(workflow, task, nodeId) { + const copy = doneCopy(workflow, nodeId, task) + const attachments = attachmentsOf(workflow, nodeId) + return frame('bot', copy.key, copy.params, copy.text, { + botId: nodeId, + step: stepLabel(workflow, nodeId), + ...(attachments ? { attachments } : {}), + }) +} + +function botReply(workflow, nodeId, text) { + return { kind: 'bot', botId: nodeId, step: stepLabel(workflow, nodeId), text } } -// A live run quotes the Bot when event.text is set; otherwise doneText fills in. +// A live run quotes the Bot when event.text is set; otherwise a frame fills in. function liveLines(workflow, task, event) { const label = summarizeTask(task) + const node = () => nodeById(workflow, event.nodeId) + const nodeName = () => node()?.name ?? event.nodeId + const reply = quoted(event.text) if (event.type === 'start') { - return [{ kind: 'system', tone: 'accent', text: `开始 · ${label} · ${names(workflow, event.started)}` }] + const nodes = names(workflow, event.started) + return [frame('system', 'Started · {task} · {nodes}', { task: label, nodes }, `开始 · ${label} · ${nodes}`, { tone: 'accent' })] } if (event.type === 'fail') { - const node = nodeById(workflow, event.nodeId) if (event.recovered === true && event.started.length > 0) { const target = names(workflow, event.started) - const text = liveText(event.text, `${node?.name ?? event.nodeId} 失败,已返回 ${target} 重试`) return [ - { kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), text }, - { kind: 'system', tone: 'bad', text: `↺ 失败恢复 → ${target}` }, + reply + ? botReply(workflow, event.nodeId, reply) + : frame('bot', '{node} failed; went back to {nodes} to retry', { node: nodeName(), nodes: target }, `${nodeName()} 失败,已返回 ${target} 重试`, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) }), + frame('system', '↺ Recovered from failure → {nodes}', { nodes: target }, `↺ 失败恢复 → ${target}`, { tone: 'bad' }), ] } - const text = liveText(event.text, `${node?.name ?? event.nodeId} 这一步失败了,停在本节点。`) - return [{ kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), text }] + return [reply + ? botReply(workflow, event.nodeId, reply) + : frame('bot', '{node} failed this step and stops here.', { node: nodeName() }, `${nodeName()} 这一步失败了,停在本节点。`, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) })] } if (event.type === 'retry') { - const node = nodeById(workflow, event.nodeId) - return [{ kind: 'system', tone: 'bad', text: `↻ ${node?.name ?? event.nodeId} 失败后重试` }] + return [frame('system', '↻ {node} retried after a failure', { node: nodeName() }, `↻ ${nodeName()} 失败后重试`, { tone: 'bad' })] } if (event.type === 'complete') { - const node = nodeById(workflow, event.nodeId) - const lines = [{ - kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), - text: liveText(event.text, `${node?.name ?? event.nodeId} finished this step.`), - }] - if (event.started.length > 1) lines.push({ kind: 'system', text: `∥ 并行分支开始:${names(workflow, event.started)}` }) + const lines = [reply + ? botReply(workflow, event.nodeId, reply) + : frame('bot', '{node} finished this step.', { node: nodeName() }, `${nodeName()} 完成了这一步。`, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) })] + if (event.started.length > 1) { + const started = names(workflow, event.started) + lines.push(frame('system', '∥ Parallel branches started: {nodes}', { nodes: started }, `∥ 并行分支开始:${started}`)) + } if (event.joined && event.layerSize > 1 && event.started.length > 0) { - lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) + const started = names(workflow, event.started) + lines.push(frame('system', '⇢ Parallel branches joined (join) → {nodes}', { nodes: started }, `⇢ 并行分支汇合(join)→ ${started}`)) + } + if (event.finished) { + lines.push(frame('system', '✓ Done · {task} · {steps} steps', { task: label, steps: workflow.nodes.length }, `✓ 完成 · ${label} · ${workflow.nodes.length} 步`, { tone: 'ok' })) } - if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ 完成 · ${summarizeTask(task)} · ${workflow.nodes.length} 步` }) return lines } if (event.type === 'note') return [{ kind: 'system', tone: event.tone, text: event.text }] @@ -77,45 +140,50 @@ function liveLines(workflow, task, event) { export function linesFor(workflow, task, event) { if (event.live === true) return liveLines(workflow, task, event) const label = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() + const node = () => nodeById(workflow, event.nodeId) + const nodeName = () => node()?.name ?? event.nodeId if (event.type === 'start') { - return [{ kind: 'system', tone: 'accent', text: `任务 ${label} 已进入 Workflow,${names(workflow, event.started)} 认领` }] + const nodes = names(workflow, event.started) + return [frame('system', 'Task {task} entered the workflow; {nodes} claimed it', { task: label, nodes }, `任务 ${label} 已进入 Workflow,${nodes} 认领`, { tone: 'accent' })] } if (event.type === 'fail') { - const node = nodeById(workflow, event.nodeId) if (event.recovered === true && event.started.length > 0) { const target = names(workflow, event.started) return [ - { kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), - text: `${node?.name ?? event.nodeId} 失败,已返回 ${target} 重试` }, - { kind: 'system', tone: 'bad', text: `↺ 失败恢复 → ${target}` }, + frame('bot', '{node} failed; went back to {nodes} to retry', { node: nodeName(), nodes: target }, `${nodeName()} 失败,已返回 ${target} 重试`, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) }), + frame('system', '↺ Recovered from failure → {nodes}', { nodes: target }, `↺ 失败恢复 → ${target}`, { tone: 'bad' }), ] } - const text = workflow.id === DELIVERY_ID && event.nodeId === 'tester' + const snapshot = workflow.id === DELIVERY_ID && event.nodeId === 'tester' + const text = snapshot ? '快照测试 notice.snap 不一致(旧文案快照未更新)。自动重试 1/2,并已通知 Coder 更新快照。' - : `${node?.name ?? event.nodeId} 这一步失败了,停在本节点。` - return [{ kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), text }] + : `${nodeName()} 这一步失败了,停在本节点。` + return [snapshot + ? frame('bot', 'Snapshot test notice.snap does not match (old copy snapshot not updated). Retried 1/2 automatically, and Coder was asked to update the snapshot.', undefined, text, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) }) + : frame('bot', '{node} failed this step and stops here.', { node: nodeName() }, text, { botId: event.nodeId, step: stepLabel(workflow, event.nodeId) })] } if (event.type === 'retry') { - const node = nodeById(workflow, event.nodeId) - const text = workflow.id === DELIVERY_ID && event.nodeId === 'tester' - ? '↻ Tester 失败后自动重试 · Coder 已推送快照更新' - : `↻ ${node?.name ?? event.nodeId} 失败后自动重试` - return [{ kind: 'system', tone: 'bad', text }] + const snapshot = workflow.id === DELIVERY_ID && event.nodeId === 'tester' + return [snapshot + ? frame('system', '↻ Tester retried automatically after a failure · Coder pushed the snapshot update', undefined, '↻ Tester 失败后自动重试 · Coder 已推送快照更新', { tone: 'bad' }) + : frame('system', '↻ {node} retried automatically after a failure', { node: nodeName() }, `↻ ${nodeName()} 失败后自动重试`, { tone: 'bad' })] } if (event.type === 'complete') { - const lines = [{ - kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), - text: doneText(workflow, event.nodeId, task), - ...(attachmentsOf(workflow, event.nodeId) ? { attachments: attachmentsOf(workflow, event.nodeId) } : {}), - }] - if (event.started.length > 1) lines.push({ kind: 'system', text: `∥ 并行分支开始:${names(workflow, event.started)}` }) + const lines = [completeLine(workflow, task, event.nodeId)] + if (event.started.length > 1) { + const started = names(workflow, event.started) + lines.push(frame('system', '∥ Parallel branches started: {nodes}', { nodes: started }, `∥ 并行分支开始:${started}`)) + } if (event.joined && event.layerSize > 1 && event.started.length > 0) { - lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) + const started = names(workflow, event.started) + lines.push(frame('system', '⇢ Parallel branches joined (join) → {nodes}', { nodes: started }, `⇢ 并行分支汇合(join)→ ${started}`)) + } + if (event.finished) { + lines.push(frame('system', '✓ Done · {task} · {steps} steps', { task: summarizeTask(task), steps: workflow.nodes.length }, `✓ 完成 · ${summarizeTask(task)} · ${workflow.nodes.length} 步`, { tone: 'ok' })) } - if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ 完成 · ${summarizeTask(task)} · ${workflow.nodes.length} 步` }) return lines } - if (event.type === 'reset') return [{ kind: 'system', text: '↺ 已重置运行' }] + if (event.type === 'reset') return [frame('system', '↺ Run reset', undefined, '↺ 已重置运行')] if (event.type === 'note') return [{ kind: 'system', text: event.text }] return [] } diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 8d36641b6..6bf3491f1 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -226,6 +226,8 @@ export function createWorkflowService({ push(workflowId, [{ kind: 'system', tone: 'bad', + key: '{node} timed out: no reply for {minutes} minutes, the run was marked failed.', + params: { node: node?.name ?? nodeId, minutes }, text: `${node?.name ?? nodeId} 超时:${minutes} 分钟没有回包,run 已标记失败。`, }], now()) endRun(item, run) @@ -469,7 +471,14 @@ export function createWorkflowService({ }) for (const hole of gated.blocked) { const node = nodeById(item, hole.id) - lines.push({ kind: 'system', tone: 'bad', text: `${node?.name ?? hole.id} 不能开始:缺少输入 ${hole.missing.join(', ')}。` }) + const missing = hole.missing.join(', ') + lines.push({ + kind: 'system', + tone: 'bad', + key: '{node} cannot start: missing input {missing}.', + params: { node: node?.name ?? hole.id, missing }, + text: `${node?.name ?? hole.id} 不能开始:缺少输入 ${missing}。`, + }) } return { run: next, lines } } @@ -520,10 +529,13 @@ export function createWorkflowService({ warn(`flybot-workflow: step ${nodeId} was not handed over: ${code}`) if (tries <= HANDOFF_RETRY_DELAYS_MS.length) { const delay = HANDOFF_RETRY_DELAYS_MS[tries - 1] + const seconds = delay / 1000 push(item.id, [{ kind: 'system', tone: 'bad', - text: `${node.name} 没有接到这一步(${code}),${delay / 1000} 秒后重试(${tries}/${HANDOFF_RETRY_DELAYS_MS.length})。`, + key: '{node} did not take this step ({code}); retrying in {seconds}s ({tries}/{total}).', + params: { node: node.name, code, seconds, tries, total: HANDOFF_RETRY_DELAYS_MS.length }, + text: `${node.name} 没有接到这一步(${code}),${seconds} 秒后重试(${tries}/${HANDOFF_RETRY_DELAYS_MS.length})。`, }], now()) setTimerAt('retry', current.id, nodeId, delay, () => hostSerial(() => afterStep(item.id, current.id)) .catch(error => warn(`flybot-workflow: the retry for step ${nodeId} failed: ${error?.message ?? error}`))) @@ -532,6 +544,8 @@ export function createWorkflowService({ push(item.id, [{ kind: 'system', tone: 'bad', + key: '{node} did not take this step ({code}); {total} retries did not land, the run failed.', + params: { node: node.name, code, total: HANDOFF_RETRY_DELAYS_MS.length }, text: `${node.name} 没有接到这一步(${code}),重试 ${HANDOFF_RETRY_DELAYS_MS.length} 次仍未落地,run 已标记失败。`, }], now()) // The run cannot finish without this step: end it the way every other @@ -677,9 +691,15 @@ export function createWorkflowService({ let run = { ...blankRun(item, task, now(), []), live: true } if (ephemeral) run.ephemeral = ephemeral const at = now() + const taskLabel = summarizeTask(task) push(item.id, [ - { kind: 'time', text: '刚刚' }, - { kind: 'system', text: `任务已分配 · ${summarizeTask(task)} → ${item.name}` }, + { kind: 'time' }, + { + kind: 'system', + key: 'Task assigned · {task} → {workflow}', + params: { task: taskLabel, workflow: item.name }, + text: `任务已分配 · ${taskLabel} → ${item.name}`, + }, ], at) const stepped = applyOp(item, run, { type: 'start' }) run = stepped.run @@ -737,7 +757,13 @@ export function createWorkflowService({ // #1544: a parked run starts later without its caller in hand; keep it, or the ceiling is lost. ...(task.callerBotId ? { callerBotId: task.callerBotId } : {}), }) - push(item.id, [{ kind: 'system', text: `排队 · ${summarizeTask(task)}(前面 ${lane.queue.length - 1} 个)` }], now()) + const ahead = lane.queue.length - 1 + push(item.id, [{ + kind: 'system', + key: 'Queued · {task} ({ahead} ahead)', + params: { task: summarizeTask(task), ahead }, + text: `排队 · ${summarizeTask(task)}(前面 ${ahead} 个)`, + }], now()) write() } @@ -921,7 +947,13 @@ export function createWorkflowService({ } state.workflows.push(item) const memberNames = item.nodes.map(node => node.name).join('、') - push(item.id, [{ kind: 'system', text: `Workflow「${item.name}」已创建 · 成员 ${memberNames}` }], now()) + const members = item.nodes.map(node => node.name).join(', ') + push(item.id, [{ + kind: 'system', + key: 'Workflow "{name}" created · members {members}', + params: { name: item.name, members }, + text: `Workflow「${item.name}」已创建 · 成员 ${memberNames}`, + }], now()) write() return present() } catch (error) { @@ -1059,7 +1091,12 @@ export function createWorkflowService({ run = { ...run, paused: true, updatedAt: now() } storeRun(workflowId, run) syncTimers(item, run) - push(workflowId, [{ kind: 'system', text: `⏸ 已暂停 · ${summarizeTask(run.task)}` }], now()) + push(workflowId, [{ + kind: 'system', + key: '⏸ Paused · {task}', + params: { task: summarizeTask(run.task) }, + text: `⏸ 已暂停 · ${summarizeTask(run.task)}`, + }], now()) write() return present() }, @@ -1076,7 +1113,12 @@ export function createWorkflowService({ run = { ...run, paused: false, updatedAt: now() } storeRun(workflowId, run) syncTimers(item, run) - push(workflowId, [{ kind: 'system', text: `▶ 已继续 · ${summarizeTask(run.task)}` }], now()) + push(workflowId, [{ + kind: 'system', + key: '▶ Resumed · {task}', + params: { task: summarizeTask(run.task) }, + text: `▶ 已继续 · ${summarizeTask(run.task)}`, + }], now()) write() if (typeof spawn !== 'function') return present() return hostSerial(async () => { @@ -1105,7 +1147,13 @@ export function createWorkflowService({ lane.active = lane.active.filter(itemRun => itemRun.id !== runId) lane.last = ended lane.history = [...lane.history, { task: ended.task, status: ended.status, at: ended.updatedAt }].slice(-20) - push(workflowId, [{ kind: 'system', tone: 'bad', text: `■ 已停止 · ${summarizeTask(run.task)}` }], now()) + push(workflowId, [{ + kind: 'system', + tone: 'bad', + key: '■ Stopped · {task}', + params: { task: summarizeTask(run.task) }, + text: `■ 已停止 · ${summarizeTask(run.task)}`, + }], now()) write() return { ok: true, ids } } diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js index ab4224609..60872aee7 100644 --- a/plugins/workflow/test/assign.test.js +++ b/plugins/workflow/test/assign.test.js @@ -39,7 +39,8 @@ test('assigning a board task starts the run at the first Bot and posts the claim const claim = delivery(assigned).messages.filter(message => message.tone === 'accent').at(-1) assert.match(claim.text, /#1281/) assert.match(claim.text, /Planner/) - assert.ok(delivery(assigned).messages.some(message => message.text.includes('任务已分配'))) + // Timeline rows no longer all carry `text` (#1489): a time row is a stamp. + assert.ok(delivery(assigned).messages.some(message => message.text?.includes('任务已分配'))) assert.equal(svc.assign({ workflowId: DELIVERY_ID, taskId: '', title: 'x' }).ok, false) }) @@ -54,7 +55,7 @@ test('advance walks the parallel join: Deployer stays queued until both branches assert.equal(mid.nodeStatus.reviewer, 'running') assert.equal(mid.nodeStatus.tester, 'running') assert.equal(mid.nodeStatus.deployer, 'queued') - assert.ok(delivery(opened).messages.some(message => message.text.includes('并行分支开始'))) + assert.ok(delivery(opened).messages.some(message => message.text?.includes('并行分支开始'))) const half = step('reviewer', 'done') assert.equal(delivery(half).run.nodeStatus.deployer, 'queued') @@ -62,7 +63,7 @@ test('advance walks the parallel join: Deployer stays queued until both branches const joined = step('tester', 'done') assert.equal(delivery(joined).run.nodeStatus.deployer, 'running') - assert.ok(delivery(joined).messages.some(message => message.text.includes('汇合'))) + assert.ok(delivery(joined).messages.some(message => message.text?.includes('汇合'))) const finished = step('deployer', 'done') const done = delivery(finished).run diff --git a/plugins/workflow/test/timeline-locale.test.js b/plugins/workflow/test/timeline-locale.test.js new file mode 100644 index 000000000..c9bb38fd8 --- /dev/null +++ b/plugins/workflow/test/timeline-locale.test.js @@ -0,0 +1,457 @@ +// #1489: the stage timeline speaks the shell's language, and its clock is read +// from each line's own stamp instead of a literal 「刚刚」 written once at emit +// time. The renderer below is the one client.test.js mounts; it is copied here +// so this file loads alone. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { linesFor } from '../src/narrate.js' +import { DEFAULT_STEP_TIMEOUT_MINUTES, STEP_DEADLINE_MS, createWorkflowService } from '../src/service.js' +import { DELIVERY_ID, deliveryWorkflow } from '../src/template.js' + +const live = { inst: null, idx: 0 } +const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { + const value = typeof next === 'function' ? next(slot.value) : next + if (!Object.is(value, slot.value)) { slot.value = value; inst.root.schedule() } + }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + const prev = inst.hooks[i] + const changed = !prev || !deps || deps.length !== prev.deps?.length || deps.some((dep, k) => !Object.is(dep, prev.deps[k])) + if (!prev) inst.hooks[i] = { deps } + if (changed) { inst.hooks[i].deps = deps; inst.root.pending.push({ slot: inst.hooks[i], fn }) } + }, + useRef(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: init } + return inst.hooks[i] + }, +} + +function mount(Component, props = {}) { + const root = { instances: new Map(), pending: [], rendering: false, dirty: false, tree: undefined } + const expand = (node, path) => { + if (node === null || node === undefined || typeof node === 'boolean') return undefined + if (typeof node !== 'object') return String(node) + if (Array.isArray(node)) return node.map((child, i) => expand(child, `${path}.${i}`)) + if (typeof node.type === 'function') { + let inst = root.instances.get(path) + if (!inst) { inst = { hooks: [], root }; root.instances.set(path, inst) } + inst.used = true + live.inst = inst + live.idx = 0 + return expand(node.type({ ...node.props, children: node.children }), `${path}/c`) + } + return { type: node.type, props: node.props, children: node.children.map((child, i) => expand(child, `${path}.${i}`)).flat(Infinity).filter(child => child !== undefined) } + } + const render = () => { + root.rendering = true + for (const inst of root.instances.values()) inst.used = false + root.tree = expand({ type: Component, props, children: [] }, 'r') + const stale = [] + for (const [path, inst] of root.instances) if (!inst.used) stale.push(path) + for (const path of stale) { + for (const slot of root.instances.get(path).hooks) slot?.cleanup?.() + root.instances.delete(path) + } + const run = root.pending.splice(0) + for (const { slot, fn } of run) { slot.cleanup?.(); const out = fn(); slot.cleanup = typeof out === 'function' ? out : undefined } + root.rendering = false + if (root.dirty || root.pending.length) { root.dirty = false; render() } + } + root.schedule = () => { if (root.rendering) root.dirty = true; else render() } + render() + return { + get tree() { return root.tree }, + unmount() { for (const inst of root.instances.values()) for (const slot of inst.hooks) slot?.cleanup?.(); root.instances.clear() }, + } +} + +const findAll = (node, pred, out = []) => { + if (Array.isArray(node)) { for (const child of node) findAll(child, pred, out); return out } + if (!node || typeof node !== 'object') return out + if (pred(node)) out.push(node) + for (const child of node.children ?? []) findAll(child, pred, out) + return out +} +const textOf = node => (typeof node === 'string' ? node : node ? (node.children ?? []).map(textOf).join('') : '') + +globalThis.document = { + listeners: [], + getElementById() { return null }, + createElement: () => ({ id: '', textContent: '' }), + head: { appendChild() {} }, + querySelector() { return null }, + addEventListener(name, fn, capture) { this.listeners.push({ name, fn, capture }) }, + removeEventListener(name, fn) { this.listeners = this.listeners.filter(item => item.fn !== fn) }, +} + +function loadClient() { + const modules = new Map() + const window = { + __ModuleLoader__: { + load: ({ id, factory }) => modules.set(id, factory(name => (name === 'react' ? react : {}))), + }, + } + new Function('window', readFileSync(new URL('../client.js', import.meta.url), 'utf8'))(window) + return modules.get('@flybot/workflow') +} + +const client = loadClient() +const T0 = Date.now() + +async function settle() { + for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)) +} + +const fill = (text, params) => (params + ? text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) + : text) + +// The host's t() as @deepseek-ai/dsh-client-locale implements it: a key it does +// not know comes back as itself with params filled, a known key comes from the +// dictionary. Only the keys FlyBot core already ships are translated here. +function hostFor(lang) { + const dict = lang === 'zh' + ? { + Tasks: '任务', + 'just now': '刚刚', + '{n} min': '{n} 分钟', + '{n} h': '{n} 小时', + '{n} d': '{n} 天', + 'In progress': '进行中', + } + : {} + return (key, params) => fill(dict[key] ?? key, params) +} + +function lane(messages) { + return { + id: 'delivery', + name: 'Delivery lane', + description: '', + chain: 'Planner → Writer', + preview: 'Waiting for a task', + activeCount: 0, + queueDepth: 0, + trigger: 'manual', + layers: [['planner'], ['writer']], + nodes: [ + { id: 'planner', name: 'Planner', role: 'plan', x: 200, y: 24 }, + { id: 'writer', name: 'Writer', role: 'write', x: 200, y: 140 }, + ], + edges: [{ from: 'planner', to: 'writer' }], + messages, + } +} + +async function stageTree(messages, lang, graph = lane(messages)) { + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { ...prev, t: hostFor(lang) } + try { + const call = async (name) => (name === 'workflow/state' + ? { workflows: [graph], templates: [] } + : { ok: true, workflows: [graph] }) + // The stage drops a bot line whose sender the roster does not know (#?): + // register the run's own senders so both sides of a real timeline render. + const byId = Object.fromEntries([...new Set(messages.filter(line => line.kind === 'bot').map(line => line.botId))] + .map(id => [id, { id, name: id }])) + const view = mount(client.Section, { roster: { bots: [], byId }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0].props.onClick() + await settle() + const tree = view.tree + view.unmount() + return tree + } finally { + globalThis.__flybot__ = prev + } +} + +const stageText = async (messages, lang, graph) => textOf(await stageTree(messages, lang, graph)) + +// Only the timeline itself: the sidebar and the run strip show workflow data, +// which is not copy this change localizes. +const stageBubbles = async (messages, lang, graph) => { + const tree = await stageTree(messages, lang, graph) + return findAll(tree, node => typeof node.props?.className === 'string' && /(^|\s)wf-(sys|bub|ts)(\s|$)/.test(node.props.className)) + .map(textOf).join('\n') +} + +// What service.js pushes after #1489: a key + params for the renderer, the +// Chinese text kept only as the fallback for rows written before the key. +const EVENTS = [ + { id: 'e1', kind: 'system', key: 'Task assigned · {task} → {workflow}', params: { task: '#1489 · Timeline copy', workflow: 'Delivery lane' }, text: '任务已分配 · #1489 · Timeline copy → Delivery lane' }, + { id: 'e2', kind: 'system', key: 'Queued · {task} ({ahead} ahead)', params: { task: '#1489 · Timeline copy', ahead: 2 }, text: '排队 · #1489 · Timeline copy(前面 2 个)' }, + { id: 'e3', kind: 'system', key: 'Workflow "{name}" created · members {members}', params: { name: 'Delivery lane', members: 'Planner, Writer' }, text: 'Workflow「Delivery lane」已创建 · 成员 Planner、Writer' }, + { id: 'e4', kind: 'system', key: '⏸ Paused · {task}', params: { task: '#1489 · Timeline copy' }, text: '⏸ 已暂停 · #1489 · Timeline copy' }, + { id: 'e5', kind: 'system', key: '▶ Resumed · {task}', params: { task: '#1489 · Timeline copy' }, text: '▶ 已继续 · #1489 · Timeline copy' }, + { id: 'e6', kind: 'system', tone: 'bad', key: '■ Stopped · {task}', params: { task: '#1489 · Timeline copy' }, text: '■ 已停止 · #1489 · Timeline copy' }, + { id: 'e7', kind: 'system', tone: 'bad', key: '{node} cannot start: missing input {missing}.', params: { node: 'Writer', missing: 'brief' }, text: 'Writer 不能开始:缺少输入 brief。' }, + { id: 'e8', kind: 'system', tone: 'bad', key: '{node} did not take this step ({code}); retrying in {seconds}s ({tries}/{total}).', params: { node: 'Writer', code: 'deny', seconds: 1, tries: 1, total: 2 }, text: 'Writer 没有接到这一步(deny),1 秒后重试(1/2)。' }, + { id: 'e9', kind: 'system', tone: 'bad', key: '{node} did not take this step ({code}); {total} retries did not land, the run failed.', params: { node: 'Writer', code: 'deny', total: 2 }, text: 'Writer 没有接到这一步(deny),重试 2 次仍未落地,run 已标记失败。' }, + { id: 'e10', kind: 'system', tone: 'bad', key: '{node} timed out: no reply for {minutes} minutes, the run was marked failed.', params: { node: 'Writer', minutes: 10 }, text: 'Writer 超时:10 分钟没有回包,run 已标记失败。' }, +] + +const CHINESE_FRAMES = ['任务已分配', '排队 ·', '已创建 · 成员', '已暂停', '已继续', '已停止', '不能开始', '没有接到这一步', '超时'] + +test('the English shell renders the timeline event lines in English', async () => { + const text = await stageText(EVENTS, 'en') + assert.ok(text.includes('Task assigned · #1489 · Timeline copy → Delivery lane'), `assigned line missing from: ${text}`) + assert.ok(text.includes('Queued · #1489 · Timeline copy (2 ahead)')) + assert.ok(text.includes('Workflow "Delivery lane" created · members Planner, Writer')) + assert.ok(text.includes('⏸ Paused · #1489 · Timeline copy')) + assert.ok(text.includes('▶ Resumed · #1489 · Timeline copy')) + assert.ok(text.includes('■ Stopped · #1489 · Timeline copy')) + assert.ok(text.includes('Writer cannot start: missing input brief.')) + assert.ok(text.includes('Writer did not take this step (deny); retrying in 1s (1/2).')) + assert.ok(text.includes('Writer did not take this step (deny); 2 retries did not land, the run failed.')) + assert.ok(text.includes('Writer timed out: no reply for 10 minutes, the run was marked failed.')) + for (const frame of CHINESE_FRAMES) assert.equal(text.includes(frame), false, `the English shell still shows the Chinese frame ${frame}`) +}) + +test('the Chinese shell renders the same timeline event lines in Chinese', async () => { + const text = await stageText(EVENTS, 'zh') + assert.ok(text.includes('任务已分配 · #1489 · Timeline copy → Delivery lane'), `assigned line missing from: ${text}`) + assert.ok(text.includes('排队 · #1489 · Timeline copy(前面 2 个)')) + assert.ok(text.includes('Workflow「Delivery lane」已创建 · 成员 Planner, Writer')) + assert.ok(text.includes('⏸ 已暂停 · #1489 · Timeline copy')) + assert.ok(text.includes('▶ 已继续 · #1489 · Timeline copy')) + assert.ok(text.includes('■ 已停止 · #1489 · Timeline copy')) + assert.ok(text.includes('Writer 不能开始:缺少输入 brief。')) + assert.ok(text.includes('Writer 没有接到这一步(deny),1 秒后重试(1/2)。')) + assert.ok(text.includes('Writer 没有接到这一步(deny),重试 2 次仍未落地,run 已标记失败。')) + assert.ok(text.includes('Writer 超时:10 分钟没有回包,run 已标记失败。')) + assert.equal(text.includes('Task assigned'), false, 'the Chinese shell still shows the English frame') + assert.equal(text.includes('cannot start'), false, 'the Chinese shell still shows the English failure frame') +}) + +test('a timestamp is read from its own at, so it ages instead of freezing at 刚刚', async () => { + const rows = [ + { id: 't1', kind: 'time', at: T0 - 2 * 3_600_000, text: '刚刚' }, + { id: 't2', kind: 'time', at: T0 - 3 * 86_400_000 }, + { id: 't3', kind: 'time', at: T0 - 45 * 86_400_000 }, + { id: 't4', kind: 'time', at: T0 - 1_000 }, + ] + const text = await stageText(rows, 'en') + assert.ok(text.includes('2 h'), `two hours should read "2 h": ${text}`) + assert.ok(text.includes('3 d'), `three days should read "3 d": ${text}`) + assert.ok(text.includes('just now')) + assert.equal(text.includes('刚刚'), false, 'a stamped row must not paint the stored literal') + assert.match(text, /(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2}/, 'older than 30 days should show a date') +}) + +test('the Chinese shell ages the same timestamps in Chinese', async () => { + const rows = [ + { id: 't1', kind: 'time', at: T0 - 2 * 3_600_000 }, + { id: 't2', kind: 'time', at: T0 - 3 * 86_400_000 }, + { id: 't3', kind: 'time', at: T0 - 1_000 }, + ] + const text = await stageText(rows, 'zh') + assert.ok(text.includes('2 小时'), `two hours should read "2 小时": ${text}`) + assert.ok(text.includes('3 天')) + assert.ok(text.includes('刚刚')) + assert.equal(text.includes(' just now'), false) +}) + +test('a row without at, or without key, still renders the stored text', async () => { + const legacy = await stageText([ + { id: 't1', kind: 'time', text: '刚刚' }, + { id: 's1', kind: 'system', text: '任务已分配 · #1489 · Timeline copy → Delivery lane' }, + ], 'en') + assert.ok(legacy.includes('刚刚'), 'a time row with no at keeps its stored text') + assert.ok(legacy.includes('任务已分配 · #1489 · Timeline copy → Delivery lane'), 'a system row with no key keeps its stored text') +}) + +test('the producer pushes a key and params, and keeps the Chinese text as the fallback', async () => { + const svc = createWorkflowService({ persist: false, now: () => 1_700_000_000_000, bots: () => [] }) + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 1 }) + const rowOf = state => state.workflows.find(item => item.id === DELIVERY_ID) + const lineWith = (state, key) => rowOf(state).messages.filter(line => line.key === key).at(-1) + const opened = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1489', title: 'Timeline copy' }) + const row = rowOf(opened) + const stamp = row.messages[0] + const assigned = row.messages[1] + assert.equal(stamp.kind, 'time') + assert.equal(stamp.text, undefined, 'a time row no longer carries the literal 刚刚') + assert.equal(stamp.at, 1_700_000_000_000, 'the push stamps the row') + assert.equal(assigned.key, 'Task assigned · {task} → {workflow}') + assert.deepEqual(assigned.params, { task: '#1489 · Timeline copy', workflow: row.name }) + assert.match(assigned.text, /^任务已分配 · /, 'the Chinese text stays as the fallback') + + const second = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1490', title: 'Queue me' }) + const queued = rowOf(second).messages.at(-1) + assert.equal(queued.key, 'Queued · {task} ({ahead} ahead)') + assert.deepEqual(queued.params, { task: '#1490 · Queue me', ahead: 0 }) + assert.match(queued.text, /^排队 · /) + + const paused = svc.pause({ workflowId: DELIVERY_ID }) + const pauseLine = lineWith(paused, '⏸ Paused · {task}') + assert.ok(pauseLine, 'the pause line must carry its key') + assert.match(pauseLine.text, /^⏸ 已暂停 · /) + + const resumed = await svc.resume({ workflowId: DELIVERY_ID }) + assert.ok(lineWith(resumed, '▶ Resumed · {task}'), 'the resume line must carry its key') + + const run = rowOf(resumed).runs.find(item => item.status === 'running') + assert.ok(run, 'a live run to stop') + const stopped = await svc.reset({ workflowId: DELIVERY_ID, runId: run.id }) + const stopLine = lineWith(stopped, '■ Stopped · {task}') + assert.ok(stopLine, 'the stop line must carry its key') + assert.match(stopLine.text, /^■ 已停止 · /) + + const created = svc.create({ name: 'Fresh lane', nodes: [{ id: 'a', name: 'Planner' }], layers: [['a']] }) + const fresh = created.workflows.find(item => item.name === 'Fresh lane') + const createdLine = fresh.messages.at(-1) + assert.equal(createdLine.key, 'Workflow "{name}" created · members {members}') + assert.deepEqual(createdLine.params, { name: 'Fresh lane', members: 'Planner' }) + assert.match(createdLine.text, /^Workflow「Fresh lane」已创建 · 成员 /) +}) + +// The whole frame set the stage timeline emits — narrate.js, not just the service +// lines above — around a real run. +const REAL = (() => { + const svc = createWorkflowService({ persist: false, now: () => 1_700_000_000_000, bots: () => [] }) + const rowOf = state => state.workflows.find(item => item.id === DELIVERY_ID) + svc.assign({ workflowId: DELIVERY_ID, taskId: '#1489', title: 'Timeline copy' }) + for (const nodeId of ['planner', 'writer', 'coder', 'tester', 'reviewer', 'deployer']) { + svc.advance({ workflowId: DELIVERY_ID, nodeId, outcome: 'done' }) + } + svc.assign({ workflowId: DELIVERY_ID, taskId: '#1490', title: 'Reset me' }) + svc.reset({ workflowId: DELIVERY_ID }) + return rowOf(svc.state()).messages +})() + +const NARRATE_FRAMES = ['开始 ·', '并行分支开始', '并行分支汇合', '✓ 完成', '↺ 已重置运行', '已停止', '任务已分配', '排队 ·', '失败', '重试'] + +test('the English shell renders a real run timeline — every narrate.js frame included — in English', async () => { + const text = await stageBubbles(REAL, 'en') + assert.ok(text.includes('Task assigned · #1489 · Timeline copy'), `assigned line missing from: ${text}`) + assert.ok(text.includes('Started · #1489 · Timeline copy · Planner'), `start frame missing from: ${text}`) + assert.ok(text.includes('∥ Parallel branches started: Reviewer ‖ Tester'), `parallel frame missing from: ${text}`) + assert.ok(text.includes('⇢ Parallel branches joined (join) → Deployer'), `join frame missing from: ${text}`) + assert.ok(text.includes('✓ Done · #1489 · Timeline copy · 6 steps'), `done frame missing from: ${text}`) + assert.ok(text.includes('↺ Run reset'), `reset frame missing from: ${text}`) + assert.ok(text.includes('Planner finished this step.'), `the bot fallback line missing from: ${text}`) + for (const frame of NARRATE_FRAMES) assert.equal(text.includes(frame), false, `the English shell still shows the Chinese frame ${frame}`) +}) + +test('the Chinese shell renders the same real run timeline in Chinese', async () => { + const text = await stageBubbles(REAL, 'zh') + assert.ok(text.includes('开始 · #1489 · Timeline copy · Planner'), `start frame missing from: ${text}`) + assert.ok(text.includes('∥ 并行分支开始:Reviewer ‖ Tester'), `parallel frame missing from: ${text}`) + assert.ok(text.includes('⇢ 并行分支汇合(join)→ Deployer'), `join frame missing from: ${text}`) + assert.ok(text.includes('✓ 完成 · #1489 · Timeline copy · 6 步'), `done frame missing from: ${text}`) + assert.ok(text.includes('↺ 已重置运行'), `reset frame missing from: ${text}`) + assert.ok(text.includes('Planner 完成了这一步。'), `the bot fallback line missing from: ${text}`) + for (const frame of ['Started ·', 'Parallel branches started', 'Parallel branches joined', '✓ Done ·', '↺ Run reset']) { + assert.equal(text.includes(frame), false, `the Chinese shell still shows the English frame ${frame}`) + } +}) + +// The demo timeline (a run that is not live) is written by linesFor() only, so +// its canned copy and its recovered/retry frames are checked on the producer. +test('a non-live run frame carries an English key and keeps the Chinese text', async () => { + const workflow = deliveryWorkflow() + const task = { id: '#1489', title: 'Timeline copy' } + const rows = [ + ...linesFor(workflow, task, { type: 'start', started: ['planner'] }), + ...linesFor(workflow, task, { type: 'fail', nodeId: 'reviewer', started: ['coder'], recovered: true, layerSize: 2 }), + ...linesFor(workflow, task, { type: 'fail', nodeId: 'writer', started: [], layerSize: 1 }), + ...linesFor(workflow, task, { type: 'retry', nodeId: 'tester', started: [], layerSize: 1 }), + ...linesFor(workflow, task, { type: 'complete', nodeId: 'writer', started: ['reviewer', 'tester'], joined: true, layerSize: 2, finished: false }), + ...linesFor(workflow, task, { type: 'complete', nodeId: 'deployer', started: [], layerSize: 0, finished: true }), + ...linesFor(workflow, task, { type: 'reset' }), + ] + for (const row of rows) assert.equal(typeof row.key, 'string', `a non-live frame lost its key: ${JSON.stringify(row)}`) + for (const row of rows) assert.match(row.text, /[\u4e00-\u9fff]/, `the Chinese fallback text is missing: ${JSON.stringify(row)}`) + + const text = await stageText(rows, 'en') + assert.equal(/[\u4e00-\u9fff]/.test(text), false, `the English shell still shows Chinese copy: ${text}`) + assert.ok(text.includes('Task #1489 Timeline copy entered the workflow; Planner claimed it')) + assert.ok(text.includes('Reviewer failed; went back to Coder to retry')) + assert.ok(text.includes('↺ Recovered from failure → Coder')) + assert.ok(text.includes('Writer failed this step and stops here.')) + assert.ok(text.includes('↻ Tester retried automatically after a failure · Coder pushed the snapshot update')) + assert.ok(text.includes('∥ Parallel branches started: Reviewer ‖ Tester')) + assert.ok(text.includes('⇢ Parallel branches joined (join) → Reviewer ‖ Tester')) + assert.ok(text.includes('Draft done (86 chars → 31)')) + assert.ok(text.includes('✓ Done · #1489 · Timeline copy · 6 steps')) + assert.ok(text.includes('↺ Run reset')) + + const zh = await stageText(rows, 'zh') + assert.ok(zh.includes('任务 #1489 Timeline copy 已进入 Workflow,Planner 认领')) + assert.ok(zh.includes('Writer 这一步失败了,停在本节点。')) + assert.ok(zh.includes('↻ Tester 失败后自动重试 · Coder 已推送快照更新')) + assert.ok(zh.includes('✓ 完成 · #1489 · Timeline copy · 6 步')) +}) + +test('the stage clock is 12px on the readable ink token, still centred', () => { + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const rule = /\.wf-ts\{([^}]*)\}/.exec(source) + assert.ok(rule, 'the .wf-ts rule is missing') + assert.doesNotMatch(rule[1], /font-size:\s*11(?:\.\d+)?px/, '.wf-ts is still the 11px stamp') + assert.match(rule[1], /font-size:\s*1[2-9](?:\.\d+)?px/, '.wf-ts must be at least 12px') + assert.equal(rule[1].includes('--bt-ink-3'), false, '.wf-ts still uses the lowest ink token') + assert.match(rule[1], /color:\s*var\(--bt-ink-2\b/) + assert.match(rule[1], /text-align:\s*center/) +}) + +// #1530 landed after the reviewed tip: a step nobody answers now times out, and +// that line is the same stage timeline. Driven through the real service and its +// injected timer, so the frame is checked where it is emitted, not in a fixture. +test('the deadline timeout frame reads in the shell language, from the real service', async () => { + const queue = new Map() + let seq = 0 + const svc = createWorkflowService({ + persist: false, + now: () => 1_700_000_000_000, + bots: () => [{ id: 'main', name: 'Chief', role: 'lead', isMain: true, remote: false }], + spawn: async (spec) => `eph-${spec.nodeId}-${++seq}`, + release: async () => {}, + dispatch: async () => ({ ok: true }), + setTimer: (fn, ms) => { + const id = ++seq + queue.set(id, { fn, due: Math.max(1, Number(ms) || 0) }) + return id + }, + clearTimer: (id) => queue.delete(id), + }) + const made = svc.define({ + name: 'Timeout lane', + nodes: [{ id: 'plan', name: 'Planner', role: 'plan', prompt: 'Write the plan.' }], + edges: [], + }) + assert.equal(made.ok, true, JSON.stringify(made.reasons ?? made)) + const flow = made.workflows.find(item => item.name === 'Timeout lane') + await svc.assign({ workflowId: flow.id, taskId: '#1489', title: 'Stuck step' }) + + const deadline = [...queue.values()].find(entry => entry.due === STEP_DEADLINE_MS) + assert.ok(deadline, 'the running step armed its deadline') + await deadline.fn() + + const row = svc.state().workflows.find(item => item.id === flow.id) + const line = row.messages + .filter(message => message.key === '{node} timed out: no reply for {minutes} minutes, the run was marked failed.') + .at(-1) + assert.ok(line, 'the timeout frame must carry its key') + assert.deepEqual(line.params, { node: 'Planner', minutes: DEFAULT_STEP_TIMEOUT_MINUTES }) + assert.match(line.text, new RegExp(`^Planner 超时:${DEFAULT_STEP_TIMEOUT_MINUTES} 分钟没有回包`)) + + const en = await stageText(row.messages, 'en') + assert.ok(en.includes(`Planner timed out: no reply for ${DEFAULT_STEP_TIMEOUT_MINUTES} minutes, the run was marked failed.`), `the English deadline frame is missing from: ${en}`) + assert.equal(/[\u4e00-\u9fff]/.test(en), false, `the English shell still shows Chinese copy: ${en}`) + + const zh = await stageText(row.messages, 'zh') + assert.ok(zh.includes(`Planner 超时:${DEFAULT_STEP_TIMEOUT_MINUTES} 分钟没有回包`), `the Chinese deadline frame is missing from: ${zh}`) +})