From 56ecbec18d14fe6ae36561bc37a9f90ad5888686 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:13:08 +0000 Subject: [PATCH 1/4] fix: give the daily digest the one next-run rule (#713) --- changelog.d/713.fixed.md | 1 + docs/architecture.md | 16 +- ...17-one-next-run-decision-three-surfaces.md | 87 ++++++++ src/jobs/service.ts | 35 +++- src/lib/nextRunFor.ts | 81 ++++++++ test/jobs.test.ts | 91 +++++++++ test/nextScheduledRun.test.ts | 185 +++++++++++++++++- 7 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 changelog.d/713.fixed.md create mode 100644 docs/decisions/0017-one-next-run-decision-three-surfaces.md create mode 100644 src/lib/nextRunFor.ts diff --git a/changelog.d/713.fixed.md b/changelog.d/713.fixed.md new file mode 100644 index 00000000..eba1d32e --- /dev/null +++ b/changelog.d/713.fixed.md @@ -0,0 +1 @@ +The daily digest now names the same next run the repository page and the dashboard name. A repository with a saved schedule row prints the planner's instant instead of the interval answer, a disabled repository without a row says it is disabled instead of promising a run, and a repository that is idle, busy or not ready says so rather than printing a time. diff --git a/docs/architecture.md b/docs/architecture.md index 0ca7c76a..11d865db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,9 +115,10 @@ defect. run is not a known time and never shadows one), or says that no repository runs automatically. Every source is one the route already loads, so the block cannot disagree with the sections below it. -- **One next-run rule** (`src/lib/nextRun.ts`, `nextRun`): the all-clear card and - the repository page's next-run line print one answer per repository, and which - rule answers is decided by the repository, never by the surface. A repository +- **One next-run rule** (`src/lib/nextRun.ts`, `nextRun`; `src/lib/nextRunFor.ts`, + `nextRunFor`): the all-clear card, the repository page's next-run line and the + daily digest print one answer per repository, and which rule answers is decided + by the repository, never by the surface. A repository that has saved no schedule row is answered by the interval rule, which copies the worker's due rule — `max(15, min(intervalMinutes, workCooldownMinutes))` after the last run (`src/jobs/service.ts`, `workDue`) — instead of the discovery @@ -131,7 +132,14 @@ defect. disagree with the page below it. The dashboard route passes open work, the active job and instance readiness per repository because those live outside `RepoRow`; the two surfaces therefore print one string, and - the wording says "next run", never an estimate. The newest job is read on its own, not + the wording says "next run", never an estimate. The daily digest is the third + surface of the same rule (#713): `buildDigest` answers through `nextRunFor`, + which delegates the choice to `nextTime` when the repository has a schedule row + and to `nextRun` when it does not, and the mail owns only the Chinese sentence, + never a second formula. It therefore carries the instant the page carries, + says the repository is disabled when there is no row, and keeps the planner's + instant for a disabled repository that still has a row — `enabled` is never + consulted before a saved row, on either surface. The newest job is read on its own, not from the job-table slice: the table is paged and filtered, and `?jobsPage=2` or `?status=` must not change the sentence the page opens with. The first-run checklist becomes the detail of the same action and does not render a second diff --git a/docs/decisions/0017-one-next-run-decision-three-surfaces.md b/docs/decisions/0017-one-next-run-decision-three-surfaces.md new file mode 100644 index 00000000..d56e188d --- /dev/null +++ b/docs/decisions/0017-one-next-run-decision-three-surfaces.md @@ -0,0 +1,87 @@ +# 0017 — one next-run decision, three surfaces + +Status: **accepted** (2026-11-15) +Date: 2026-11-15 +Supersedes: nothing; it applies the one-next-run rule (`10ea236622`, stated at +`docs/architecture.md`, "One next-run rule") to the third surface + +## Context + +Three surfaces answer "when does this repository run again?", and they have to +agree: + +- the repository page's next-run line, rendered by `nextRunLine` and + `plannedNextLine` (`src/views/pages.ts`); +- the dashboard's all-clear card, which reaches the same two renderers through + the route's per-repository facts; +- the daily digest email, built by `buildDigest` (`src/jobs/service.ts`) and + rendered by `src/email/templates.ts`. + +The first two were brought onto one rule by two commits: `10ea236622` made the +page and the card print `nextRun`'s answer, and `f859f242` extended the rule to a +repository with a saved schedule row, whose cadence the planner +(`src/schedules/next.ts`, `nextTime`) owns. The digest was never moved. It +computed `last_run_at + min(interval_minutes, workCooldownMinutes)` for every +repository with `auto_iterate = 1`, never read the schedule row, never applied +the 15-minute floor and never consulted `repos.enabled`. + +One repository therefore had two answers. A repository scheduled `0 9 * * 1-5` +in `Asia/Shanghai`, with a 1440-minute interval and its last run ten minutes ago, +was promised a run within minutes by the email and named the next weekday +morning by the page. A disabled repository with no row — a state only the +settings route can leave behind, because `/repos/:id/disable` zeroes both fields — +was told by the email that a run was coming, while the page said it would never +start. + +What the email could not reach was the *choice* between the interval rule and the +planner. The instants already live in one place each (`nextRun` and `nextTime`), +but the choice lived in `src/views/pages.ts` as two non-exported helpers, and a +job module must not import the view layer. + +## Decision + +Add `src/lib/nextRunFor.ts` (`nextRunFor`) and let the digest answer through it. + +- It returns `{ at, state }` with its own union: `NextRunState` plus + `unknown-unreadable` and `unknown-closed`. The two planner-only answers are not + added to `NextRunState`, because `nextRunLine` switches on that union + exhaustively and a new member would drag a view change into this slice. +- With a schedule row it delegates to `scheduleFromRow` + `nextTime`, and answers + `due` when the instant has passed, else `scheduled`; a row the planner refuses + is `unknown-unreadable`, a window that has ended is `unknown-closed`. A saved + row owns the cadence whether or not the repository is enabled, exactly as on + the page. +- With no row it delegates to `nextRun` (`src/lib/nextRun.ts`), which owns the + floor through `dueAfterMs` and the reasons a run will not start — disabled, + not-ready, auto-off, active-job, idle, waiting-first-run. +- `instanceReadyFor(instanceImage, status)` carries the fallback both existing + surfaces already apply: on a deployment with no instance image there is no + instance to wait for. +- `buildDigest` maps `state` to its own Chinese sentence, reusing the page's + wording where the state exists. The email owns the wording; it does not own + the rule. + +## Rejected + +- **Keeping the formula in the digest.** That is the defect: a third copy of the + rule with two guards missing. +- **Widening `NextRunState`.** `nextRunLine`'s exhaustive switch would have to + handle the planner's two no-instant answers, turning an email fix into a view + change. +- **Moving `nextRunLine`/`plannedNextLine` into `src/lib/`.** It re-points every + reference in `src/views/pages.ts` and rewrites the route's `ScheduledNext` + contract for a one-line email contradiction. + +## Consequences + +- The *choice* between a schedule row and the interval rule now has two + implementations — the route's (`src/app.ts`, the dashboard and repository page + facts) and the helper's — while the instant itself stays single-sourced in + `nextTime`/`dueAfterMs`. That is the accepted cost of not restructuring the + view layer in this slice; a later card can collapse the route onto the helper + if it is ever worth the diff. +- `src/jobs` stays a caller of the rule, never an owner of it. +- The digest starts printing the page's state sentence for a no-row repository + that will not run, instead of a formatted time. For a repository whose state is + `due` or `scheduled` the instant does not change: the floor cannot move it, + because both clamps already keep it at 15 minutes. diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 9a97d4bc..f6c7f51c 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -16,6 +16,7 @@ import { parseEvidence, parseEvidenceJson, type Evidence } from '../lib/evidence import { classifyFailure, providerRefusalCode, type EnqueueRefusalCode, type FailureCode } from '../lib/failure.js' import { parseModelRun } from '../lib/modelRun.js' import { retryDecision, reviewRetryDecision } from '../lib/retry.js' +import { instanceReadyFor, nextRunFor, type NextRunForAnswer } from '../lib/nextRunFor.js' import { jobEmailDecision } from '../lib/emailPolicy.js' import { duplicateOutcome, issueNumberFromText } from '../lib/duplicates.js' import { incCounter, observeHistogram, setGauge } from '../lib/metrics.js' @@ -2600,6 +2601,24 @@ export class Jobs { return { date: `${get('year')}-${get('month')}-${get('day')}`, hour: Number(get('hour')) } } + // The digest owns only the Chinese wording: the instant and the state come from + // the same rule the repository page and the dashboard answer with (#713), so a + // scheduled repository cannot be promised a run the page says will not start. + private digestNextRun(answer: NextRunForAnswer): string { + switch (answer.state) { + case 'disabled': return '仓库已停用,自动运行不会开始' + case 'not-ready': return 'FlyBot 实例未就绪,自动运行不会开始' + case 'auto-off': return '自动工作已关闭' + case 'active-job': return '当前任务结束后再排' + case 'idle': return '空闲:没有开放工作项;会按发现间隔检查' + case 'waiting-first-run': return '等待首次运行' + case 'due': return '等待调度' + case 'scheduled': return this.digestTime(new Date(answer.at ?? 0)) + case 'unknown-unreadable': return 'unknown(排期无法解析)' + case 'unknown-closed': return 'unknown(排期窗口已结束)' + } + } + private digestTime(value: Date): string { return new Intl.DateTimeFormat('zh-CN', { timeZone: this.config.digest.timeZone, @@ -2622,9 +2641,19 @@ export class Jobs { const label = `${repo.upstream_owner || repo.owner}/${repo.upstream_name || repo.name}` teamLinks.push({ repo: label, url: `${this.config.baseUrl}/repos/${repo.id}#inbox` }) if (repo.auto_iterate === 1) { - const last = repo.last_run_at === null ? Number.NaN : Date.parse(repo.last_run_at) - const next = Number.isFinite(last) ? new Date(last + Math.min(repo.interval_minutes, this.config.runner.workCooldownMinutes) * 60_000) : new Date() - nextRuns.push({ repo: label, when: next.getTime() <= Date.now() ? '等待调度' : this.digestTime(next) }) + // #713: a saved schedule row owns this repository's cadence, exactly as + // it does on the repository page and the dashboard. The email used to + // compute `last_run_at + min(interval, cooldown)` here, so one + // repository had two next-run answers and a cron row was ignored. + const answer = nextRunFor({ + repo, + schedule: this.store.scheduleForRepo(repo.id), + openWork: this.store.listOpenWorkItems(repo.id).length > 0, + activeJob: this.store.activeJobForRepo(repo.id) !== undefined, + instanceReady: instanceReadyFor(this.config.flybot.instanceImage, this.store.getRepoInstance(repo.id)?.status), + workCooldownMinutes: this.config.runner.workCooldownMinutes, + }) + nextRuns.push({ repo: label, when: this.digestNextRun(answer) }) } if (this.instances === undefined || this.config.flybot.instanceImage === '') continue try { diff --git a/src/lib/nextRunFor.ts b/src/lib/nextRunFor.ts new file mode 100644 index 00000000..a6ca6f92 --- /dev/null +++ b/src/lib/nextRunFor.ts @@ -0,0 +1,81 @@ +// Which rule answers "when does this repository run again?", for a caller +// outside the view layer (#713). +// +// The rule itself already lives in one place: `nextTime` (`src/schedules/next.ts`) +// for a repository with a saved schedule row, and `nextRun` (`src/lib/nextRun.ts`) +// for one without. What the daily digest could not reach was the *choice* +// between them, because that choice lived in `src/views/pages.ts` — and a job +// module must not import the view layer. This helper owns only the choice, plus +// the two answers a saved row can have when the planner has no instant to name. +// Keeping the union here instead of widening `NextRunState` matters: `nextRunLine` +// switches on that union exhaustively, so a new member would drag a view change +// into a slice that is about the email. +import type { ScheduleRow } from '../db.js' +import { nextTime } from '../schedules/next.js' +import { scheduleFromRow } from '../schedules/tick.js' +import { nextRun, type NextRunState } from './nextRun.js' + +export type NextRunForState = NextRunState | 'unknown-unreadable' | 'unknown-closed' + +export interface NextRunForRepo { + last_run_at: string | null + interval_minutes: number + auto_iterate: number + enabled: number +} + +export interface NextRunForInput { + repo: NextRunForRepo + // Present only when the repository has a saved schedule row. Absent means the + // legacy interval rule still owns this repository's cadence. + schedule: ScheduleRow | undefined + openWork: boolean + activeJob: boolean + // Already computed by the caller through `instanceReadyFor`, so the "no + // instance image configured" fallback has exactly one implementation. + instanceReady: boolean + workCooldownMinutes: number + floorMinutes?: number + now?: number +} + +export interface NextRunForAnswer { + at: number | null + state: NextRunForState +} + +export function nextRunFor(input: NextRunForInput): NextRunForAnswer { + const now = input.now ?? Date.now() + if (input.schedule !== undefined) { + const schedule = scheduleFromRow(input.schedule) + if (schedule === null) return { at: null, state: 'unknown-unreadable' } + const at = nextTime(schedule, now) + if (at === null) return { at: null, state: 'unknown-closed' } + // A saved row owns the cadence whether or not the repository is enabled: the + // page attaches the planner's answer whenever a row exists, so consulting + // `enabled` first here would disagree with the page on exactly the + // disabled-but-scheduled repository this rule exists to keep in step. + return { at, state: at <= now ? 'due' : 'scheduled' } + } + return nextRun({ + lastRunAt: input.repo.last_run_at, + intervalMinutes: input.repo.interval_minutes, + workCooldownMinutes: input.workCooldownMinutes, + autoIterate: input.repo.auto_iterate === 1, + hasOpenWork: input.openWork, + hasActiveJob: input.activeJob, + repoEnabled: input.repo.enabled === 1, + instanceReady: input.instanceReady, + ...(input.floorMinutes === undefined ? {} : { floorMinutes: input.floorMinutes }), + now, + }) +} + +// The fallback both existing surfaces apply: on a deployment with no instance +// image there is no instance to wait for, so an absent or stopped instance is +// not a reason to say "not ready". Without this the email would print +// "FlyBot 实例未就绪" for every repository on such a box — the same defect as +// the contradiction this slice removes, pointed the other way. +export function instanceReadyFor(instanceImage: string, status: string | undefined): boolean { + return instanceImage === '' ? true : status === 'ready' +} diff --git a/test/jobs.test.ts b/test/jobs.test.ts index f8700d64..109c62f1 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -8,6 +8,8 @@ import { Jobs } from '../src/jobs/service.js' import { buildCarryOver, buildRunDigest, classifyOutput, decideNextJob, failureStreak, isMetaTitle, lastDurationMs } from '../src/jobs/continuity.js' import type { Runner, RunnerContext } from '../src/jobs/runner.js' import { encryptSecret, sha256Hex } from '../src/lib/secrets.js' +import { nextTime } from '../src/schedules/next.js' +import { scheduleFromRow } from '../src/schedules/tick.js' import { REVIEW_INSTRUCTION_MAX, REVIEW_INSTRUCTION_LABEL } from '../src/lib/followUpInstruction.js' import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' @@ -3062,3 +3064,92 @@ test('one blocked tip keeps one audit row and one notification, and a moved head await github.close() } }) + +// #713: the daily digest is a third next-run surface. It used to compute +// `last_run_at + min(interval, cooldown)` and never read the schedule row, so +// one repository had two next-run answers — the page named the planner instant, +// the email promised a run within minutes. These cases drive the pass rather +// than the template, because the faulty line was the part of the sentence no +// template test could see. +function digestZh(value: Date): string { + // The digest's own formatter, `digestTime` in src/jobs/service.ts, is private: + // build the expected string with the same Intl options instead of hard-coding + // it, so a timezone change cannot make the assertion pass by accident. + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).format(value) +} + +async function digestTextFor(repoFixture: (store: Store, userId: string) => void): Promise<{ text: string; repoId: string }> { + const config = testConfig({ digest: { hour: 0, timeZone: 'Asia/Shanghai' } }) + const store = new Store(':memory:') + const mailer = new MemoryMailer() + const jobs = new Jobs(store, config, { name: 'capture', async run() { return { status: 'running' } } }, mailer) + try { + const user = makeUser(store, { login: `digest-next-run-${Math.random().toString(36).slice(2, 8)}`, status: 'approved' }) + store.setEmailDailyDigest(user.id, true) + repoFixture(store, user.id) + await jobs.tick() + const mail = mailer.messages.find((message) => /今日摘要/.test(message.subject)) + assert.ok(mail !== undefined, 'the digest went out') + const repo = store.listRepos(user.id)[0] + assert.ok(repo !== undefined) + return { text: mail.text, repoId: repo.id } + } finally { + store.close() + } +} + +test('the digest prints the scheduled next run, not the interval answer', async () => { + const lastRunAt = new Date(Date.now() - 10 * 60_000).toISOString() + const { text } = await digestTextFor((store, userId) => { + const repo = store.createRepo({ id: 'repo_digest_cron', userId, owner: 'o', name: 'cron', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 }) + store.updateRepo(repo.id, { last_run_at: lastRunAt }) + store.saveSchedule({ id: 'sch_digest_cron', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' }) + }) + const expected = digestZh(new Date(Date.parse(lastRunAt) + 15 * 60_000)) + // The fixture separates the two answers, or the assertion below proves nothing. + assert.ok(text.includes('下次自动迭代:o/cron'), 'the digest names the repository') + assert.ok(!text.includes(`下次自动迭代:o/cron · ${expected}`), 'the interval answer must not be the scheduled one') + const digestLine = /下次自动迭代:o\/cron · ([^\n<]+)/.exec(text)?.[1] + assert.ok(digestLine !== undefined) + assert.notEqual(digestLine, expected, 'the email must not restate the interval rule for a repository with a schedule row') + // The planner instant for `0 9 * * 1-5` in Asia/Shanghai, formatted by the + // digest's own formatter, is the only other candidate. + assert.match(digestLine, /^\d{2}\/\d{2} \d{2}:\d{2}$/) +}) + +test('a disabled repository with no schedule row says it is disabled instead of promising a run', async () => { + const { text } = await digestTextFor((store, userId) => { + const repo = store.createRepo({ id: 'repo_digest_norow', userId, owner: 'o', name: 'norow', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 }) + // `/repos/:id/disable` zeroes both fields, so only the settings route leaves + // `auto_iterate = 1` with `enabled = 0`; that is the fixture this case needs. + store.updateRepo(repo.id, { enabled: 0, last_run_at: new Date(Date.now() - 10 * 60_000).toISOString() }) + }) + assert.ok(text.includes('下次自动迭代:o/norow · 仓库已停用,自动运行不会开始'), 'the digest repeats the page sentence') + assert.ok(!/下次自动迭代:o\/norow · \d\d\/\d\d/.test(text), 'no instant for a repository that will not run') +}) + +test('a disabled repository with a schedule row keeps the planner instant the page prints', async () => { + // A saved row wins over `enabled` on the page, so the digest must not start + // disagreeing with it by checking `enabled` first: that is the same defect + // pointed the other way. + const lastRunAt = new Date(Date.now() - 10 * 60_000).toISOString() + let planner: number | null = null + const { text } = await digestTextFor((store, userId) => { + const repo = store.createRepo({ id: 'repo_digest_disabled_row', userId, owner: 'o', name: 'drow', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: true, intervalMinutes: 1440 }) + store.updateRepo(repo.id, { enabled: 0, last_run_at: lastRunAt }) + const row = store.saveSchedule({ id: 'sch_digest_disabled_row', repoId: repo.id, kind: 'cron', expression: '0 9 * * 1-5', timezone: 'Asia/Shanghai' }) + const schedule = scheduleFromRow(row) + assert.ok(schedule !== null) + planner = nextTime(schedule, Date.now()) + }) + assert.ok(planner !== null) + const digestLine = /下次自动迭代:o\/drow · ([^\n<]+)/.exec(text)?.[1] + assert.equal(digestLine, digestZh(new Date(planner))) +}) diff --git a/test/nextScheduledRun.test.ts b/test/nextScheduledRun.test.ts index 2ccc301d..15923a02 100644 --- a/test/nextScheduledRun.test.ts +++ b/test/nextScheduledRun.test.ts @@ -1,9 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' import { buildApp } from '../src/app.js' -import { Store, type WorkItemRow } from '../src/db.js' +import { Store, type ScheduleRow, type WorkItemRow } from '../src/db.js' import { MockRunner } from '../src/jobs/mock.js' import { nextRun } from '../src/lib/nextRun.js' +import { instanceReadyFor, nextRunFor } from '../src/lib/nextRunFor.js' import { humanTimeShort } from '../src/lib/time.js' import { nextTime } from '../src/schedules/next.js' import { scheduleFromRow } from '../src/schedules/tick.js' @@ -230,3 +231,185 @@ test('the dashboard answers with the schedule row, not the interval, when one ex store.close() } }) + +// #713: the daily digest is the third surface of the one-next-run rule, so it +// answers through a helper that the job module can call. A repository with a +// saved schedule row is answered by the planner; every other repository keeps +// the interval rule. The email owns its Chinese wording, never the rule. +const CRON_ROW: ScheduleRow = { + id: 'sch_next_run_cron', + repo_id: 'repo_next_run_cron', + kind: 'cron', + expression: '0 9 * * 1-5', + every_ms: null, + timezone: 'Asia/Shanghai', + start_at: null, + end_at: null, + timing_revision: 1, + misfire: 'run', + last_occurrence_at: null, + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), +} + +interface NextRunForRepoFixture { + last_run_at: string | null + interval_minutes: number + auto_iterate: number + enabled: number +} + +const REPO_FOR_RUN: NextRunForRepoFixture = { + last_run_at: LAST_RUN_RECENT, + interval_minutes: INTERVAL_MINUTES, + auto_iterate: 1, + enabled: 1, +} + +function intervalAnswer(now = NOW): ReturnType { + return nextRunFor({ + repo: REPO_FOR_RUN, + schedule: undefined, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: COOLDOWN_MINUTES, + now, + }) +} + +test('a saved cron row answers the planner instant, not the interval', () => { + // Saturday 12:52Z: the next weekday 09:00 Asia/Shanghai is Monday 01:00Z, days + // away from the interval answer the digest used to print. + const now = Date.parse('2026-11-14T12:52:00Z') + const answer = nextRunFor({ + repo: { ...REPO_FOR_RUN, last_run_at: new Date(now - 10 * 60_000).toISOString() }, + schedule: CRON_ROW, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: COOLDOWN_MINUTES, + now, + }) + const schedule = scheduleFromRow(CRON_ROW) + assert.ok(schedule !== null, 'the cron row parses') + assert.equal(answer.state, 'scheduled') + assert.equal(answer.at, nextTime(schedule, now)) + assert.equal(new Date(answer.at ?? 0).toISOString(), '2026-11-16T01:00:00.000Z') + assert.notEqual(answer.at, now - 10 * 60_000 + COOLDOWN_MINUTES * 60_000, 'the interval answer is not the planner answer') +}) + +test('a row the planner cannot read, and a window that has closed, name no instant', () => { + const unreadable = nextRunFor({ + repo: REPO_FOR_RUN, + schedule: { ...CRON_ROW, expression: null }, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: COOLDOWN_MINUTES, + now: NOW, + }) + assert.equal(unreadable.state, 'unknown-unreadable') + assert.equal(unreadable.at, null) + + const now = Date.parse('2026-11-14T12:52:00Z') + const closed = nextRunFor({ + repo: REPO_FOR_RUN, + schedule: { ...CRON_ROW, end_at: Date.parse('2026-11-14T14:00:00Z') }, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: COOLDOWN_MINUTES, + now, + }) + assert.equal(closed.state, 'unknown-closed') + assert.equal(closed.at, null) +}) + +test('the interval branch keeps every state the page already words', () => { + const base = { + repo: REPO_FOR_RUN, + schedule: undefined, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: COOLDOWN_MINUTES, + now: NOW, + } + // Ten minutes of cooldown left: a time, not "due", and never the discovery + // interval the digest used to print. + const scheduled = nextRunFor(base) + assert.equal(scheduled.state, 'scheduled') + assert.equal(scheduled.at, Date.parse(LAST_RUN_RECENT) + COOLDOWN_MINUTES * 60_000) + assert.notEqual(scheduled.at, Date.parse(LAST_RUN_RECENT) + INTERVAL_MINUTES * 60_000) + + assert.equal(nextRunFor({ ...base, repo: { ...REPO_FOR_RUN, last_run_at: LAST_RUN_AT } }).state, 'due') + assert.equal(nextRunFor({ ...base, repo: { ...REPO_FOR_RUN, enabled: 0 } }).state, 'disabled') + assert.equal(nextRunFor({ ...base, repo: { ...REPO_FOR_RUN, auto_iterate: 0 } }).state, 'auto-off') + assert.equal(nextRunFor({ ...base, instanceReady: false }).state, 'not-ready') + assert.equal(nextRunFor({ ...base, activeJob: true }).state, 'active-job') + assert.equal(nextRunFor({ ...base, openWork: false }).state, 'idle') + const neverRan = nextRunFor({ ...base, repo: { ...REPO_FOR_RUN, last_run_at: null } }) + assert.equal(neverRan.state, 'waiting-first-run') + + // Every state that means "no run is scheduled" names no instant, so the email + // cannot print a formatted time for a repository that will not run. + for (const answer of [ + nextRunFor({ ...base, repo: { ...REPO_FOR_RUN, enabled: 0 } }), + nextRunFor({ ...base, instanceReady: false }), + nextRunFor({ ...base, activeJob: true }), + nextRunFor({ ...base, openWork: false }), + neverRan, + ]) { + assert.equal(answer.at, null) + } +}) + +test('a deployment with no instance image never reports the instance as not ready', () => { + // Both existing surfaces apply this fallback; the digest must not print + // "FlyBot 实例未就绪" for every repository on a box with no instance image. + assert.equal(instanceReadyFor('', undefined), true) + assert.equal(instanceReadyFor('', 'stopped'), true) + assert.equal(instanceReadyFor('ghcr.io/flybot:1', 'ready'), true) + assert.equal(instanceReadyFor('ghcr.io/flybot:1', 'stopped'), false) + const answer = nextRunFor({ + repo: REPO_FOR_RUN, + schedule: undefined, + openWork: true, + activeJob: false, + instanceReady: instanceReadyFor('', undefined), + workCooldownMinutes: COOLDOWN_MINUTES, + now: NOW, + }) + assert.notEqual(answer.state, 'not-ready') + assert.equal(answer.at, Date.parse(LAST_RUN_RECENT) + COOLDOWN_MINUTES * 60_000) +}) + +// The interval branch is the same rule as before, so the helper adds no third +// copy of the formula: it delegates to `nextRun`, which owns the floor. +test('the helper delegates the interval branch to nextRun, floor included', () => { + const answer = intervalAnswer() + const direct = nextRun({ + lastRunAt: LAST_RUN_RECENT, + intervalMinutes: 1, + workCooldownMinutes: 1, + autoIterate: true, + hasOpenWork: true, + hasActiveJob: false, + repoEnabled: true, + instanceReady: true, + now: NOW, + }) + const floored = nextRunFor({ + repo: { ...REPO_FOR_RUN, interval_minutes: 1 }, + schedule: undefined, + openWork: true, + activeJob: false, + instanceReady: true, + workCooldownMinutes: 1, + now: NOW, + }) + assert.equal(floored.at, Date.parse(LAST_RUN_RECENT) + 15 * 60_000, 'the 15-minute floor still applies') + assert.equal(floored.at, direct.at) + assert.equal(answer.state, 'scheduled') +}) From b6156f6fbece7ab13b0cd11a4986a4bf9c7329a3 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 18:19:30 +0800 Subject: [PATCH 2/4] ui: make the FlyBot instance health card structured Fixes #805 --- changelog.d/805-instance-health.changed.md | 1 + public/app.css | 3 ++ src/views/pages.ts | 36 ++++++++++++++++++++-- test/pages.test.ts | 31 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 changelog.d/805-instance-health.changed.md diff --git a/changelog.d/805-instance-health.changed.md b/changelog.d/805-instance-health.changed.md new file mode 100644 index 00000000..9889303b --- /dev/null +++ b/changelog.d/805-instance-health.changed.md @@ -0,0 +1 @@ +The FlyBot instance card now shows a status badge, a human-readable health reason, the last health-check time and the relevant Start/Stop/Reinitialize action, while the raw bridge error stays in a technical disclosure instead of the page message. diff --git a/public/app.css b/public/app.css index 9338c641..9080f9d3 100644 --- a/public/app.css +++ b/public/app.css @@ -1093,6 +1093,9 @@ a.list-row:hover strong { color: var(--accent); } border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--paper); } .workspace-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +[data-instance-health] .badge { vertical-align: baseline; } +[data-instance-health] details { margin-top: 8px; } +[data-instance-health] pre { margin: 6px 0 0; font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; } .add-choice { border: 1px solid var(--line); border-radius: var(--radius-md); padding: 14px; margin-bottom: 12px; } .add-choice h3 { margin: 0 0 6px; font-size: 15px; } diff --git a/src/views/pages.ts b/src/views/pages.ts index c6da7eb5..2922f1cd 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -299,6 +299,18 @@ function statusLabel(t: Translate, status: string): string { return labels[status] ?? status } +// #805: a raw bridge error is not a status. Map the common transport failures to +// one short sentence, and keep the original text for the technical disclosure. +function instanceReason(t: Translate, error: string | null | undefined): string { + const text = String(error ?? '').toLowerCase() + if (text === '') return t('健康检查正常', 'Health check ok') + if (/\b502\b/.test(text)) return t('Bridge 返回 502', 'Bridge returned 502') + if (/fetch failed|econnrefused|enotfound|etimedout|network/.test(text)) return t('Bridge 无法连接', 'Bridge unreachable') + if (/container|docker|not running/.test(text)) return t('容器未运行', 'Container is not running') + if (/model|gateway|provider/.test(text)) return t('模型端点不可达', 'Model endpoint unreachable') + return t('健康检查失败', 'Health check failed') +} + // A webhook delivery stores its state as an internal code. The console names the // three states in the reader's language and keeps the code only as the filter // value, so `/admin/webhooks?status=` links keep filtering exactly as before. @@ -1404,6 +1416,22 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer : runnable ? { body: t('立即运行会创建这个仓库的第一个任务;每次运行只开一个 PR。', 'Run now creates the first job for this repository; each run opens at most one pull request.'), action: { formAction: `/repos/${repo.id}/run`, label: t('立即运行', 'Run now'), csrf: csrf(viewer) } } : { body: t('先修复 FlyBot 实例或 GitHub 凭据,然后再运行。', 'Fix the FlyBot instance or the GitHub credential first, then run.'), action: { label: t('实例与凭据在本页的连接卡片里。', 'The instance and the credential are on the connection card of this page.') } } + const instanceHealthTone = config.flybot.instanceImage === '' + ? 'busy' + : instance === undefined + ? 'busy' + : instance.status === 'ready' + ? 'good' + : instance.status === 'failed' ? 'bad' : 'busy' + const instanceHealthStatus = config.flybot.instanceImage === '' + ? t('未启用', 'not enabled') + : instance === undefined ? t('未创建', 'not created') : statusLabel(t, instance.status) + const instanceHealthReason = config.flybot.instanceImage === '' + ? t('这个部署没有启用每仓库 FlyBot 实例。', 'This deployment has no per-repository FlyBot instance.') + : instance === undefined + ? t('实例尚未创建。', 'The instance has not been created yet.') + : instance.status === 'ready' ? t('健康检查正常', 'Health check ok') : instanceReason(t, instance.last_error) + const instanceLastHealth = instance === undefined || instance.last_health_at === null ? t('未知', 'unknown') : humanTimeShort(instance.last_health_at) // #472 slice 3: the instance state is a page-level message, not a banner in the // team card. `failed` is an error; any other non-ready state is informational. if (config.flybot.instanceImage === '') { @@ -1414,7 +1442,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer pageMessages.push({ level: instance.status === 'failed' ? 'error' : 'warning', key: 'instance-status', - text: `${t(`FlyBot 实例状态:${statusLabel(t, instance.status)}`, `FlyBot instance status: ${statusLabel(t, instance.status)}`)}${instance.last_error === null ? '' : ` · ${instance.last_error}`}`, + text: `${t('FlyBot 实例:', 'FlyBot instance: ')}${instanceHealthStatus} · ${instanceHealthReason}`, }) } const instanceActions = config.flybot.instanceImage === '' @@ -1486,12 +1514,14 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer

${escapeHtml(t('读出开着的 issue 和 PR,按优先级排序、标出重复项,并点名下一项。只读:不开分支,也不开 PR。', 'Reads the open issues and pull requests, ranks them by priority, links duplicates and names the next item. Read-only: no branch and no pull request.'))}

${triageForm} ` - const instanceCard = `
+ const instanceCard = `
${escapeHtml(t('每仓库 FlyBot', 'PER-REPOSITORY FLYBOT'))}

${escapeHtml(advanced ? t('实例与团队', 'Instance & team') : t('FlyBot 状态', 'FlyBot status'))}

-

${instance === undefined ? escapeHtml(t('未创建', 'Not created')) : `${badge(t, instance.status)}${advanced ? ` ${escapeHtml(instance.container)}` : ''}`}${instanceReady && advanced ? ` · ${escapeHtml(t('任务先发给总控,再由负责人执行', 'jobs go to the Chief, then the Lead'))}` : ''}

+

${escapeHtml(instanceHealthStatus)} · ${escapeHtml(instanceHealthReason)}

+

${escapeHtml(t('最近检查', 'Last health check'))} ${escapeHtml(instanceLastHealth)}${advanced && instance !== undefined ? ` · ${escapeHtml(instance.container)}` : ''}${instanceReady && advanced ? ` · ${escapeHtml(t('任务先发给总控,再由负责人执行', 'jobs go to the Chief, then the Lead'))}` : ''}

+ ${instance === undefined || instance.last_error === null ? '' : `
${escapeHtml(t('技术详情', 'Technical detail'))}
${escapeHtml(instance.last_error)}
`}
${advanced ? instanceActions : ''}
diff --git a/test/pages.test.ts b/test/pages.test.ts index 53e6d3c8..e497bf4d 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -2064,3 +2064,34 @@ test('both repository lists translate the reviewer badge', () => { store.close() } }) + +test('a failed FlyBot instance shows a human reason and keeps the raw error behind details', async () => { + const config = testConfig({ flybot: { ...testConfig().flybot, instanceImage: 'image' } }) + const store = new Store(':memory:') + const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'instance-health-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_instance_health', userId: user.id, owner: 'o', name: 'health', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.saveRepoInstance({ + repo_id: repo.id, user_id: user.id, container: 'fledge-health', network: 'n', data_dir: '/tmp/health', bridge_script: '/tmp/bridge', + dsh_port: 1, bridge_port: 2, model_port: 3, model_bind: '127.0.0.1', image: 'image', status: 'failed', chief_session: 'c', + team_json: null, bridge_token_enc: 'x', bridge_pid: null, log_pid: null, last_error: 'TypeError: fetch failed', + created_at: '2026-11-15T08:00:00.000Z', updated_at: '2026-11-15T08:00:00.000Z', last_health_at: '2026-11-15T08:00:00.000Z', + }) + const { cookie } = sessionFor(built, user) + const page = await built.app.inject({ method: 'GET', url: `/repos/${repo.id}`, headers: { cookie } }) + assert.equal(page.statusCode, 200) + const cardIndex = page.body.indexOf('data-instance-health') + assert.notEqual(cardIndex, -1, 'the Team section has the structured instance card') + const beforeCard = page.body.slice(0, cardIndex) + assert.match(beforeCard, /Bridge 无法连接/, 'the page message names the human reason') + assert.doesNotMatch(beforeCard, /TypeError: fetch failed/, 'the raw error is not the page-level message') + const card = page.body.slice(cardIndex) + assert.match(card, /失败/) + assert.match(card, /Bridge 无法连接/) + assert.match(card, /技术详情/) + assert.match(card, /TypeError: fetch failed/) + } finally { + await built.close() + } +}) From cad68cdbec6f8a20f386b2a194e80583357b2c80 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Sun, 15 Nov 2026 18:19:53 +0800 Subject: [PATCH 3/4] docs: describe the structured FlyBot health card --- docs/architecture.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 6a2b8765..24717c68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1553,6 +1553,13 @@ Run/Plan/Review until the state is fixed. A danger zone offers stop scheduling, stop FlyBot and delete; deletion requires typing the repository name and removes the Fledge connection, instance, jobs and tokens without touching GitHub. +The FlyBot instance card maps transport failures to a short reason (`Bridge +unreachable`, `Bridge returned 502`, `Container is not running`, `Model endpoint +unreachable`) and keeps the raw error in a technical disclosure. The page-level +message uses the same reason, never a bare `TypeError: fetch failed`, and the +card shows the last health-check time and the Start/Stop/Reinitialize action for +the current state. + ### Pull-request delivery Each repository page has a delivery list of iterate jobs with open pull requests, From 42b4f85e6833987875258f4fec212c75d165dc07 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:36:26 +0800 Subject: [PATCH 4/4] fix(ui): name every FlyBot instance state and its own action (#805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Instances.stop()` keeps the previous `last_error` on the row and a starting instance has none, so the card read that column for every non-ready status and printed `Stopped · Health check ok`. The sentence now comes from the status; only `failed` reads the error, and an empty error there is a failure. The test is a six-state, two-locale matrix: each state proves its badge, its sentence, its own action label and form path, and that no non-ready state is called healthy anywhere on the page. Refs #805 Written-by: Fledge Platform Dev-2 --- src/views/pages.ts | 16 +++++--- test/pages.test.ts | 95 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/views/pages.ts b/src/views/pages.ts index 2922f1cd..243019de 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -299,11 +299,17 @@ function statusLabel(t: Translate, status: string): string { return labels[status] ?? status } -// #805: a raw bridge error is not a status. Map the common transport failures to -// one short sentence, and keep the original text for the technical disclosure. -function instanceReason(t: Translate, error: string | null | undefined): string { +// #805: the status answers "why is this not ready" on its own; `last_error` only +// explains a failure. `Instances.stop()` leaves the previous `last_error` in place +// and a starting instance has none, so reading that column for every non-ready +// status called a stopped instance healthy. Only `failed` reads the error, and an +// empty error there means a failed check, never a healthy one. +function instanceReason(t: Translate, status: string, error: string | null | undefined): string { + if (status === 'ready') return t('健康检查正常', 'Health check ok') + if (status === 'stopped') return t('实例已停止,不会接受新任务', 'The instance is stopped and takes no new jobs') + if (status === 'starting') return t('实例正在创建,还未就绪', 'The instance is being created and is not ready yet') + if (status === 'provisioning') return t('实例正在初始化,还未就绪', 'The instance is being provisioned and is not ready yet') const text = String(error ?? '').toLowerCase() - if (text === '') return t('健康检查正常', 'Health check ok') if (/\b502\b/.test(text)) return t('Bridge 返回 502', 'Bridge returned 502') if (/fetch failed|econnrefused|enotfound|etimedout|network/.test(text)) return t('Bridge 无法连接', 'Bridge unreachable') if (/container|docker|not running/.test(text)) return t('容器未运行', 'Container is not running') @@ -1430,7 +1436,7 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer ? t('这个部署没有启用每仓库 FlyBot 实例。', 'This deployment has no per-repository FlyBot instance.') : instance === undefined ? t('实例尚未创建。', 'The instance has not been created yet.') - : instance.status === 'ready' ? t('健康检查正常', 'Health check ok') : instanceReason(t, instance.last_error) + : instanceReason(t, instance.status, instance.last_error) const instanceLastHealth = instance === undefined || instance.last_health_at === null ? t('未知', 'unknown') : humanTimeShort(instance.last_health_at) // #472 slice 3: the instance state is a page-level message, not a banner in the // team card. `failed` is an error; any other non-ready state is informational. diff --git a/test/pages.test.ts b/test/pages.test.ts index e497bf4d..040a04bd 100644 --- a/test/pages.test.ts +++ b/test/pages.test.ts @@ -2065,33 +2065,82 @@ test('both repository lists translate the reviewer badge', () => { } }) -test('a failed FlyBot instance shows a human reason and keeps the raw error behind details', async () => { +// #805: the status decides the sentence; `last_error` only explains a failure. +// `Instances.stop()` keeps the previous error and a starting instance has none, so +// every non-ready state must say what it is doing, in both languages, with its own +// action — and only `failed` may report a health failure. +test('every FlyBot instance state names itself, its own action and never calls a non-ready instance healthy', () => { const config = testConfig({ flybot: { ...testConfig().flybot, instanceImage: 'image' } }) const store = new Store(':memory:') - const built = await buildApp({ config, store, mailer: new MemoryMailer(), runner: new MockRunner(), startScheduler: false }) try { - const user = makeUser(store, { login: 'instance-health-user', status: 'approved' }) + const user = makeUser(store, { login: 'instance-health-user', status: 'approved', superadmin: true }) + const viewer = { user: store.userById(user.id)!, csrfToken: 'csrf' } const repo = store.createRepo({ id: 'repo_instance_health', userId: user.id, owner: 'o', name: 'health', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) - store.saveRepoInstance({ - repo_id: repo.id, user_id: user.id, container: 'fledge-health', network: 'n', data_dir: '/tmp/health', bridge_script: '/tmp/bridge', - dsh_port: 1, bridge_port: 2, model_port: 3, model_bind: '127.0.0.1', image: 'image', status: 'failed', chief_session: 'c', - team_json: null, bridge_token_enc: 'x', bridge_pid: null, log_pid: null, last_error: 'TypeError: fetch failed', - created_at: '2026-11-15T08:00:00.000Z', updated_at: '2026-11-15T08:00:00.000Z', last_health_at: '2026-11-15T08:00:00.000Z', - }) - const { cookie } = sessionFor(built, user) - const page = await built.app.inject({ method: 'GET', url: `/repos/${repo.id}`, headers: { cookie } }) - assert.equal(page.statusCode, 200) - const cardIndex = page.body.indexOf('data-instance-health') - assert.notEqual(cardIndex, -1, 'the Team section has the structured instance card') - const beforeCard = page.body.slice(0, cardIndex) - assert.match(beforeCard, /Bridge 无法连接/, 'the page message names the human reason') - assert.doesNotMatch(beforeCard, /TypeError: fetch failed/, 'the raw error is not the page-level message') - const card = page.body.slice(cardIndex) - assert.match(card, /失败/) - assert.match(card, /Bridge 无法连接/) - assert.match(card, /技术详情/) - assert.match(card, /TypeError: fetch failed/) + const saveInstance = (status: string, lastError: string | null): void => { + store.saveRepoInstance({ + repo_id: repo.id, user_id: user.id, container: 'fledge-health', network: 'n', data_dir: '/tmp/health', bridge_script: '/tmp/bridge', + dsh_port: 1, bridge_port: 2, model_port: 3, model_bind: '127.0.0.1', image: 'image', status, chief_session: 'c', + team_json: null, bridge_token_enc: 'x', bridge_pid: null, log_pid: null, last_error: lastError, + created_at: '2026-11-15T08:00:00.000Z', updated_at: '2026-11-15T08:00:00.000Z', last_health_at: '2026-11-15T08:00:00.000Z', + }) + } + const instanceCard = (html: string): string => { + const start = html.indexOf('data-instance-health') + assert.notEqual(start, -1, 'the Team section has the structured instance card') + const end = html.indexOf('
', start) + assert.notEqual(end, -1, 'the instance card closes') + return html.slice(start, end) + } + const summaryLine = (html: string): string => { + const card = instanceCard(html) + const end = card.indexOf('

') + assert.notEqual(end, -1, 'the instance card has a summary line') + return card.slice(0, end) + } + const cases = [ + { status: 'ready', lastError: null, zhBadge: '就绪', enBadge: 'Ready', zhReason: '健康检查正常', enReason: 'Health check ok', zhAction: '停止 FlyBot', enAction: 'Stop FlyBot', action: 'instance/stop' }, + { status: 'failed', lastError: 'TypeError: fetch failed', zhBadge: '失败', enBadge: 'Failed', zhReason: 'Bridge 无法连接', enReason: 'Bridge unreachable', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'failed', lastError: null, zhBadge: '失败', enBadge: 'Failed', zhReason: '健康检查失败', enReason: 'Health check failed', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'stopped', lastError: 'health check failed: stopped from the console', zhBadge: '已停止', enBadge: 'Stopped', zhReason: '实例已停止,不会接受新任务', enReason: 'The instance is stopped and takes no new jobs', zhAction: '启动 FlyBot', enAction: 'Start FlyBot', action: 'instance' }, + { status: 'starting', lastError: null, zhBadge: '启动中', enBadge: 'Starting', zhReason: '实例正在创建,还未就绪', enReason: 'The instance is being created and is not ready yet', zhAction: '重新初始化 FlyBot', enAction: 'Reinitialize FlyBot', action: 'instance' }, + { status: 'provisioning', lastError: 'TypeError: fetch failed', zhBadge: '初始化中', enBadge: 'Provisioning', zhReason: '实例正在初始化,还未就绪', enReason: 'The instance is being provisioned and is not ready yet', zhAction: '重新初始化 FlyBot', enAction: 'Reinitialize FlyBot', action: 'instance' }, + ] + for (const state of cases) { + saveInstance(state.status, state.lastError) + const instance = store.getRepoInstance(repo.id)! + const zh = repoPage({ config, locale: 'zh', viewer, repo, instance, jobs: [] }) + const en = repoPage({ config, locale: 'en', viewer, repo, instance, jobs: [] }) + const name = `${state.status} (${state.lastError === null ? 'no error' : 'an old error on the row'})` + assert.ok(instanceCard(zh).includes(state.zhBadge), `${name}: the zh card carries the status badge`) + assert.ok(instanceCard(en).includes(state.enBadge), `${name}: the en card carries the status badge`) + assert.ok(instanceCard(zh).includes(state.zhReason), `${name}: the zh card names the state`) + assert.ok(instanceCard(en).includes(state.enReason), `${name}: the en card names the state`) + assert.ok(instanceCard(zh).includes(state.zhAction), `${name}: the zh card offers its own action`) + assert.ok(instanceCard(en).includes(state.enAction), `${name}: the en card offers its own action`) + assert.ok(instanceCard(zh).includes(`action="/repos/${repo.id}/${state.action}"`), `${name}: the zh action posts its own form`) + assert.ok(instanceCard(en).includes(`action="/repos/${repo.id}/${state.action}"`), `${name}: the en action posts its own form`) + if (state.status === 'ready') { + assert.ok(!en.includes('FlyBot instance:'), 'a ready instance raises no page-level message') + assert.ok(!en.includes('Health check failed'), 'a ready instance never reports a failure') + continue + } + assert.ok(summaryLine(zh).includes(state.zhReason), `${name}: the zh summary line carries the reason, not the raw error`) + assert.ok(!instanceCard(zh).includes('健康检查正常'), `${name}: a non-ready instance is never called healthy in zh`) + assert.ok(!instanceCard(en).includes('Health check ok'), `${name}: a non-ready instance is never called healthy in en`) + assert.ok(!zh.includes('健康检查正常'), `${name}: the zh page never calls a non-ready instance healthy`) + assert.ok(!en.includes('Health check ok'), `${name}: the en page never calls a non-ready instance healthy`) + assert.ok(zh.includes(`FlyBot 实例:${state.zhBadge} · ${state.zhReason}`), `${name}: the zh page message repeats the status and the reason`) + assert.ok(en.includes(`FlyBot instance: ${state.enBadge} · ${state.enReason}`), `${name}: the en page message repeats the status and the reason`) + if (state.lastError !== null && state.status !== 'failed') { + assert.ok(!summaryLine(en).includes(state.lastError), `${name}: an old error is not the reason of a non-failed state`) + } + if (state.lastError !== null) { + assert.ok(instanceCard(en).includes('Technical detail'), `${name}: the raw error stays behind the disclosure`) + assert.ok(instanceCard(en).includes(state.lastError), `${name}: the disclosure keeps the original text`) + assert.ok(!en.slice(0, en.indexOf('data-instance-health')).includes(state.lastError), `${name}: the raw error is not the page-level message`) + } + } } finally { - await built.close() + store.close() } })