From 17d60e8886319cbb48703abb19fa61c4b00b25ed Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 16:18:48 +0800 Subject: [PATCH 1/4] feat: route feedback to the server FlyBot Lead and complete the triage loop (#814) --- .env.example | 10 ++ changelog.d/814-feedback-lead.added.md | 1 + docs/architecture.md | 10 ++ docs/deploy.md | 3 + docs/secrets.md | 1 + docs/security.md | 13 +++ src/app.ts | 92 ++++++++++++++- src/config.ts | 17 +++ src/db.ts | 123 +++++++++++++++++++- src/email/templates.ts | 26 +++++ src/feedback/lead.ts | 95 +++++++++++++++ src/feedback/notify.ts | 81 +++++++++++++ src/views/pages.ts | 2 + test/db.test.ts | 4 +- test/feedbackLead.test.ts | 153 +++++++++++++++++++++++++ test/scheduleLeases.test.ts | 4 +- test/scheduleOccurrences.test.ts | 4 +- test/scheduleSchema.test.ts | 4 +- 18 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 changelog.d/814-feedback-lead.added.md create mode 100644 src/feedback/lead.ts create mode 100644 src/feedback/notify.ts create mode 100644 test/feedbackLead.test.ts diff --git a/.env.example b/.env.example index 90ba7939..2252cdda 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,16 @@ USD_CNY_RATE=7.2 # balance may not cover a first job and recommends an own model. 50 CNY default. PLATFORM_QUOTA_COMFORT_CNY=50 +# Feedback triage: route new feedback to the server FlyBot's Fledge Lead. +# Prefer a repo Fledge already manages: +FLEDGE_LEAD_REPO_ID= +# Or target an externally managed FlyBot bridge: +FLEDGE_LEAD_BRIDGE_URL= +FLEDGE_LEAD_BRIDGE_TOKEN= +FLEDGE_LEAD_SESSION_ID= +# The sanitized public issue target the Lead may write to. +FEEDBACK_PUBLIC_REPO=HugoXK/fledge + # ── Service directory (console links) ───────────────────────────────────── # Point these at your own deployments if you self-host the sibling services. PORTAL_URL=https://portal.hugoxk.online diff --git a/changelog.d/814-feedback-lead.added.md b/changelog.d/814-feedback-lead.added.md new file mode 100644 index 00000000..a837570b --- /dev/null +++ b/changelog.d/814-feedback-lead.added.md @@ -0,0 +1 @@ +Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token lets the Lead read the redacted record and write a user-facing decision back through the triage API; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. A public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. diff --git a/docs/architecture.md b/docs/architecture.md index db941bb4..4f6126d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,6 +110,16 @@ name, redacted message, redacted stack, version, commit, the signed-in login and the consent version/time — never the query, body, headers, cookies or a token. Both routes carry a route-level limiter, and a 4xx never offers the report button. +The feedback lifecycle is one triage row per ref (`feedback_triage`) plus +`feedback_events`. New reports are sent to the server FlyBot's Lead through a +connected repo (`FLEDGE_LEAD_REPO_ID`) or a direct bridge; the packet carries a +per-ref signed token, not the raw stack. The Lead reads and writes through +`GET`/`POST /api/feedback/:ref` and `/triage`; every transition appends an event +and notifies the signed-in reporter in-app and by mail. A `fixed_version` at or +below the running `VERSION` sends the fixed notice once; the startup scan then +marks the record `released` and sends the “now live” notice once. Fledge never +creates the public issue: the Lead does, sanitized, and records its URL. + The dashboard's Repositories card owns the full content row; its Add repository button sits in the card header and opens a native `` with the GitHub App and fine-grained token options. `#add-repo` remains the deep link used by the diff --git a/docs/deploy.md b/docs/deploy.md index 8bc0435f..9db03e55 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -36,6 +36,9 @@ Key settings: | `SMTP_HOST/PORT/SECURE/USER/PASS` | For yeah.net: `smtp.yeah.net`, 465, secure | | `MAIL_FROM` / `ADMIN_EMAIL` | `fledge@hugoxk.online` / `admin@example.com` | | `FEEDBACK_EMAIL` | Optional feedback/error-report destination; defaults to `ADMIN_EMAIL` | +| `FLEDGE_LEAD_REPO_ID` | Repo connected to Fledge whose FlyBot Lead triages feedback | +| `FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID` | Direct bridge target when the Fledge Lead is external | +| `FEEDBACK_PUBLIC_REPO` | Sanitized public issue target; defaults to `HugoXK/fledge` | | `PLATFORM_LLM_BASE_URL` | OpenAI-compatible `/v1` endpoint | | `PLATFORM_LLM_KEY` | Platform key; never commit it | | `INITIAL_QUOTA_CNY` | Default 1 | diff --git a/docs/secrets.md b/docs/secrets.md index 0728401a..3681c1aa 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -25,6 +25,7 @@ predates this inventory — it is not a value. | `SMTP_PASS` | approval and digest mail | production `.env` | provider's send-only SMTP credential | owner | unknown | mint a new SMTP credential at the provider | `npm run mail:outbox` on a development box, or one approval mail | unknown | | `PLATFORM_LLM_KEY` | platform-funded model calls | production `.env` | one OpenAI-compatible key with a spend cap | owner | unknown | mint at the provider, then replace and restart | one platform-funded job reaches the gateway | unknown | | `FLYBOT_BRIDGE_TOKEN` | shared secret on the optional global bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one mock job round-trips a callback | unknown | +| `FLEDGE_LEAD_BRIDGE_TOKEN` | bearer token for the external Fledge Lead triage bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one feedback packet reaches the configured Lead session | unknown | | `GITHUB_APP_PRIVATE_KEY_PATH` | GitHub App authentication | file on the host, path in `.env` | App private key | owner | unknown | regenerate the key in the App settings and replace the file | install the App on a repository and list its repositories | unknown | | `GITHUB_APP_WEBHOOK_SECRET` | verifies App webhooks | production `.env` | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both the App and `.env` | redeliver one webhook and confirm it is accepted | unknown | | `INBOUND_EMAIL_SECRET` | shared secret with the inbound Email Worker | production `.env` and the worker | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | reply to one digest mail and confirm it lands | unknown | diff --git a/docs/security.md b/docs/security.md index 4837d0cb..f2d9fb6b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -188,6 +188,19 @@ rate-limited by client address, and a second click does not send a second mail. Without the consent value the row stays `captured` and nothing is mailed. A 4xx refusal is user-caused and never offers the button. +### Feedback triage + +Each feedback/error record gets a per-ref triage token derived from +`HMAC(SESSION_SECRET, "feedback-triage:")`. The token is delivered only in +the private packet to the Fledge Lead; the read and write endpoints accept it as +a bearer capability for that one ref and are rate-limited. The Lead may read the +redacted message/stack and write a conclusion, but Fledge does not publish raw +feedback or error data. A public GitHub issue is created by the Lead only after a +separate sanitized-publication consent, and the issue may @ the reporter only +when that consent is present. The triage row stores the issue/PR URLs, fixed and +released versions, user-facing message, public summary and notification state; +the raw text remains in the feedback/error row. + ### CSRF Every state-changing browser form includes a hidden `csrf` field derived from diff --git a/src/app.ts b/src/app.ts index dd9696f6..3b530c4f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,6 +19,8 @@ import { dedupeRepoNames, instanceLimitRefusal, repoLimitRefusal, type LimitRefu import { isTrialFilter } from './lib/trials.js' import { createMailer, type Mailer } from './email/mailer.js' import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js' +import { deliverFeedbackToLead, verifyTriageToken } from './feedback/lead.js' +import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js' import { notifyEmail } from './email/recipient.js' import { authenticateApiToken } from './lib/apiToken.js' import { registerGateway } from './gateway/gateway.js' @@ -4154,7 +4156,10 @@ export async function buildApp(options: BuildOptions): Promise { if (message === '') return reply.redirect(`/settings?error=${encodeURIComponent('请填写反馈内容。')}#feedback`) const id = randomId('fb') const createdAt = new Date().toISOString() + const publishConsent = body.publishConsent === '1' store.createFeedback({ id, userId: viewer.user.id, kind, message, page, createdAt }) + const triage = store.createFeedbackTriage({ ref: id, userId: viewer.user.id, userLogin: viewer.user.login, kind, publishConsent, createdAt }) + void deliverFeedbackToLead({ config, instances, triage, feedback: store.feedbackById(id) }).catch((error) => log.warn('feedback lead delivery failed', { ref: id, error: redact(String(error)) })) store.audit(viewer.user.id, 'feedback.created', `${id} kind=${kind} chars=${message.length}`) const replyTo = notifyEmail(viewer.user) try { @@ -4184,12 +4189,23 @@ export async function buildApp(options: BuildOptions): Promise { if (report === undefined) { return htmlPage(reply, 404, errorPage(config, viewer, '页面不存在', '这个报告编号不存在。', request.locale)) } - if (form(request).consent !== '1') { + const reportBody = form(request) + if (reportBody.consent !== '1') { return htmlPage(reply, 400, errorPage(config, viewer, '需要授权', '请先勾选“我已知晓并授权 Fledge 平台读取这些失败数据”,再提交报告。', request.locale)) } if (!store.claimErrorReport(ref, ERROR_REPORT_CONSENT_VERSION)) { return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `这个报告(${ref})已经发送过了,谢谢。`, request.locale)) } + const publishConsent = request.viewer !== undefined && reportBody.publish === '1' + const triage = store.createFeedbackTriage({ + ref, + userId: report.user_id, + userLogin: report.user_login, + kind: 'error', + publishConsent, + createdAt: report.created_at, + }) + void deliverFeedbackToLead({ config, instances, triage, error: report }).catch((error) => log.warn('error report lead delivery failed', { ref, error: redact(String(error)) })) try { await mailer.send({ to: config.mail.feedbackEmail, @@ -4216,6 +4232,75 @@ export async function buildApp(options: BuildOptions): Promise { return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `谢谢。编号:${ref}。开发者会查看这个错误的类型、页面和时间;报告不包含你的输入、token 或请求体。`, request.locale)) }) + const triageAuthorized = (request: FastifyRequest, ref: string): boolean => { + const header = String(request.headers.authorization ?? '') + const token = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : '' + return verifyTriageToken(config.sessionSecret, ref, token) + } + + app.get('/api/feedback/:ref', async (request, reply) => { + if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } }) + const ref = String((request.params as Record).ref ?? '') + if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } }) + const triage = store.feedbackTriageByRef(ref) + if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } }) + return { + triage, + feedback: store.feedbackById(ref) ?? null, + error: store.errorReportByRef(ref) ?? null, + events: store.listFeedbackEvents(ref), + } + }) + + app.post('/api/feedback/:ref/triage', async (request, reply) => { + if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } }) + const ref = String((request.params as Record).ref ?? '') + if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } }) + const triage = store.feedbackTriageByRef(ref) + if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } }) + const body = (request.body ?? {}) as Record + const allowed = new Set(['user_error', 'duplicate', 'needs_info', 'confirmed', 'issue_opened', 'pr_opened', 'fixed', 'wontfix']) + const status = typeof body.status === 'string' ? body.status.trim() : '' + if (!allowed.has(status)) return reply.code(400).send({ error: { type: 'invalid_status', message: 'Unsupported triage status' } }) + const userMessage = redact(String(body.user_message ?? '')).trim().slice(0, 1000) + const publicSummary = redact(String(body.public_summary ?? '')).trim().slice(0, 1000) + const issueUrl = String(body.issue_url ?? '').trim() + const prUrl = String(body.pr_url ?? '').trim() + const fixedVersion = String(body.fixed_version ?? '').trim() + const isGithubUrl = (value: string): boolean => { + if (value === '') return true + try { + const url = new URL(value) + return url.protocol === 'https:' && url.hostname === 'github.com' + } catch { + return false + } + } + if (!isGithubUrl(issueUrl) || !isGithubUrl(prUrl)) return reply.code(400).send({ error: { type: 'invalid_url', message: 'Only https://github.com links are accepted' } }) + if (fixedVersion !== '' && parseVersion(fixedVersion) === null) return reply.code(400).send({ error: { type: 'invalid_version', message: 'fixed_version must be SemVer' } }) + store.updateFeedbackTriage(ref, { + status, + user_message: userMessage === '' ? null : userMessage, + public_summary: publicSummary === '' ? null : publicSummary, + issue_url: issueUrl === '' ? null : issueUrl, + pr_url: prUrl === '' ? null : prUrl, + fixed_version: fixedVersion === '' ? null : fixedVersion, + }) + store.addFeedbackEvent(ref, status, userMessage.slice(0, 300), 'fledge-lead') + store.audit(triage.user_id, 'feedback.triaged', `${ref} ${status}`) + await notifyFeedbackReporter({ + store, + mailer, + ref, + userId: triage.user_id, + status, + userMessage, + issueUrl: issueUrl === '' ? null : issueUrl, + prUrl: prUrl === '' ? null : prUrl, + }) + return { ok: true, status } + }) + app.post('/settings/tokens', { config: { rateLimit: destructiveLimit } }, async (request, reply) => { const viewer = approvedCsrfGuard(request) if (requireRecentAuth(request, reply, viewer, 'token.create')) return reply @@ -5582,7 +5667,10 @@ export async function buildApp(options: BuildOptions): Promise { }, 60_000) sweeper.unref() - if (options.startScheduler !== false) jobs.start() + if (options.startScheduler !== false) { + jobs.start() + void notifyReleasedFeedback({ store, mailer }).catch((error) => log.warn('released feedback notification failed', { error: redact(String(error)) })) + } return { app, diff --git a/src/config.ts b/src/config.ts index a35abb97..1b81ee67 100644 --- a/src/config.ts +++ b/src/config.ts @@ -88,6 +88,16 @@ export interface Config { /** Below this remaining balance the first-run checklist recommends an own model. */ comfortCny: number } + // #814: the server-deployed FlyBot's Fledge Lead. `leadRepoId` uses a repo + // Fledge already manages; the bridge trio targets an externally managed + // instance. `publicRepo` is the sanitized issue target the Lead may write to. + feedback: { + leadRepoId: string + leadBridgeUrl: string + leadBridgeToken: string + leadSessionId: string + publicRepo: string + } runner: { mode: RunnerMode bridgeUrl: string @@ -344,6 +354,13 @@ export function loadConfig(overrides: Partial = {}): Config { usdCny: number('USD_CNY_RATE', 7.2), comfortCny: number('PLATFORM_QUOTA_COMFORT_CNY', 50), }, + feedback: { + leadRepoId: text('FLEDGE_LEAD_REPO_ID'), + leadBridgeUrl: text('FLEDGE_LEAD_BRIDGE_URL').replace(/\/$/, ''), + leadBridgeToken: text('FLEDGE_LEAD_BRIDGE_TOKEN'), + leadSessionId: text('FLEDGE_LEAD_SESSION_ID'), + publicRepo: text('FEEDBACK_PUBLIC_REPO', 'HugoXK/fledge'), + }, runner: { mode: (text('RUNNER_MODE', 'mock') as RunnerMode) || 'mock', bridgeUrl: text('FLYBOT_BRIDGE_URL', 'http://127.0.0.1:8787/run'), diff --git a/src/db.ts b/src/db.ts index 8233ec64..9f4d2c23 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1900,6 +1900,41 @@ CREATE TABLE IF NOT EXISTS error_reports ( ); CREATE INDEX IF NOT EXISTS error_reports_created_idx ON error_reports(created_at DESC); CREATE INDEX IF NOT EXISTS error_reports_status_idx ON error_reports(status, created_at DESC); + +-- #814: the triage lifecycle, keyed by the feedback id / error ref. The user's +-- raw text stays in feedback/error_reports; this row holds the decision, +-- links, versions, consent and notification state. +CREATE TABLE IF NOT EXISTS feedback_triage ( + ref TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + user_login TEXT, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', + publish_consent INTEGER NOT NULL DEFAULT 0, + publish_consent_at TEXT, + public_summary TEXT, + user_message TEXT, + issue_url TEXT, + pr_url TEXT, + fixed_version TEXT, + released_version TEXT, + released_at TEXT, + last_notified_status TEXT, + notified_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS feedback_triage_status_idx ON feedback_triage(status, created_at DESC); + +CREATE TABLE IF NOT EXISTS feedback_events ( + id TEXT PRIMARY KEY, + ref TEXT NOT NULL REFERENCES feedback_triage(ref) ON DELETE CASCADE, + status TEXT NOT NULL, + detail TEXT, + actor TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS feedback_events_ref_idx ON feedback_events(ref, created_at DESC); ` function clean(params: unknown[]): unknown[] { @@ -2010,6 +2045,40 @@ export interface ErrorReportRow { consented_at: string | null } +// #814: the lifecycle record for one feedback/error ref. It is separate from +// `feedback` and `error_reports` so the user text is never rewritten by a +// triage update, and an anonymous error report can carry a lifecycle without a +// users row. +export interface FeedbackTriageRow { + ref: string + user_id: string | null + user_login: string | null + kind: string + status: string + publish_consent: number + publish_consent_at: string | null + public_summary: string | null + user_message: string | null + issue_url: string | null + pr_url: string | null + fixed_version: string | null + released_version: string | null + released_at: string | null + last_notified_status: string | null + notified_at: string | null + created_at: string + updated_at: string +} + +export interface FeedbackEventRow { + id: string + ref: string + status: string + detail: string | null + actor: string | null + created_at: string +} + export class Store { readonly db: Database @@ -2020,9 +2089,9 @@ export class Store { this.db.exec('PRAGMA foreign_keys = ON;') this.db.exec('PRAGMA busy_timeout = 5000;') const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0) - if (previousVersion > 41) { + if (previousVersion > 42) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (41)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (42)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -2045,7 +2114,7 @@ export class Store { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } this.migrateIdentity() - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 41)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 42)};`) } // #544 slice 3B-B2: a database written by slice B1 already has the occurrence @@ -5684,6 +5753,54 @@ export class Store { return this.all('SELECT * FROM error_reports ORDER BY created_at DESC, rowid DESC LIMIT ?', Math.max(1, Math.min(100, limit))) } + feedbackById(id: string): FeedbackRow | undefined { + return this.get('SELECT * FROM feedback WHERE id = ?', id) + } + + createFeedbackTriage(input: { + ref: string + userId: string | null + userLogin: string | null + kind: string + publishConsent: boolean + createdAt?: string + }): FeedbackTriageRow { + const at = input.createdAt ?? nowIso() + this.run( + 'INSERT INTO feedback_triage (ref, user_id, user_login, kind, status, publish_consent, publish_consent_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + input.ref, input.userId, input.userLogin, input.kind, 'new', input.publishConsent ? 1 : 0, input.publishConsent ? at : null, at, at, + ) + return this.feedbackTriageByRef(input.ref)! + } + + feedbackTriageByRef(ref: string): FeedbackTriageRow | undefined { + return this.get('SELECT * FROM feedback_triage WHERE ref = ?', ref) + } + + updateFeedbackTriage(ref: string, patch: Partial>): void { + const entries = Object.entries(patch).filter(([, value]) => value !== undefined) + if (entries.length === 0) return + const sets = entries.map(([key]) => `${key} = ?`).join(', ') + this.run(`UPDATE feedback_triage SET ${sets}, updated_at = ? WHERE ref = ?`, ...entries.map(([, value]) => value), nowIso(), ref) + } + + addFeedbackEvent(ref: string, status: string, detail: string | null = null, actor: string | null = null): void { + this.run('INSERT INTO feedback_events (id, ref, status, detail, actor, created_at) VALUES (?, ?, ?, ?, ?, ?)', `fev_${randomBytes(6).toString('hex')}`, ref, status, detail, actor, nowIso()) + } + + listFeedbackEvents(ref: string, limit = 50): FeedbackEventRow[] { + return this.all('SELECT * FROM feedback_events WHERE ref = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', ref, Math.max(1, Math.min(100, limit))) + } + + listFeedbackForRelease(limit = 50): FeedbackTriageRow[] { + return this.all("SELECT * FROM feedback_triage WHERE fixed_version IS NOT NULL AND released_version IS NULL AND user_id IS NOT NULL AND status IN ('fixed','pr_opened') ORDER BY created_at ASC LIMIT ?", Math.max(1, Math.min(100, limit))) + } + + markFeedbackReleased(ref: string, version: string): boolean { + const at = nowIso() + return this.runChanges("UPDATE feedback_triage SET status = 'released', released_version = ?, released_at = ?, updated_at = ? WHERE ref = ? AND released_version IS NULL", version, at, at, ref) === 1 + } + listNotifications(userId: string, limit = 20): NotificationRow[] { return this.all('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', userId, limit) } diff --git a/src/email/templates.ts b/src/email/templates.ts index 91edb7bb..828e5be7 100644 --- a/src/email/templates.ts +++ b/src/email/templates.ts @@ -469,3 +469,29 @@ export function errorReportMail(input: { ].join('\n'), } } + +// #814: a reporter-facing update. The Lead supplies the user message; Fledge +// owns the delivery and the ref. No raw error data is added here. +export function feedbackUpdateMail(input: { + ref: string + status: string + userMessage: string + issueUrl: string | null + prUrl: string | null + releasedVersion: string | null +}): { subject: string; text: string } { + return { + subject: `[Fledge] 反馈更新 · ${input.status}`.slice(0, 200), + text: [ + `你反馈的问题(${input.ref})有更新:${input.status}`, + '', + input.userMessage, + ...(input.issueUrl === null ? [] : ['', `Issue: ${input.issueUrl}`]), + ...(input.prUrl === null ? [] : ['', `PR: ${input.prUrl}`]), + ...(input.releasedVersion === null ? [] : ['', `已上线版本:v${input.releasedVersion}`]), + '', + '这是一封自动通知;回复请联系维护者。', + mailFooter(), + ].join('\n'), + } +} diff --git a/src/feedback/lead.ts b/src/feedback/lead.ts new file mode 100644 index 00000000..de9fd9c3 --- /dev/null +++ b/src/feedback/lead.ts @@ -0,0 +1,95 @@ +// #814: one report, one private triage packet to the Fledge Lead inside the +// server-deployed FlyBot. Two deployment shapes share the packet: +// - a repo Fledge already manages (`FLEDGE_LEAD_REPO_ID`); +// - an externally managed bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). +// The report itself must never fail because the Lead is offline; the caller +// keeps the row and the maintainer mail. +import type { Config } from '../config.js' +import type { ErrorReportRow, FeedbackRow, FeedbackTriageRow } from '../db.js' +import { hmacHex, redact, safeEqual } from '../lib/secrets.js' + +export interface LeadTransport { + sendGuidance(repoId: string, target: string, text: string): Promise +} + +export type LeadMode = 'repo' | 'bridge' | 'none' + +export function leadMode(config: Config): LeadMode { + if (config.feedback.leadRepoId !== '') return 'repo' + if (config.feedback.leadBridgeUrl !== '' && config.feedback.leadBridgeToken !== '' && config.feedback.leadSessionId !== '') return 'bridge' + return 'none' +} + +export function triageToken(sessionSecret: string, ref: string): string { + return hmacHex(sessionSecret, `feedback-triage:${ref}`) +} + +export function verifyTriageToken(sessionSecret: string, ref: string, supplied: string): boolean { + return supplied !== '' && safeEqual(supplied, triageToken(sessionSecret, ref)) +} + +export interface TriagePacketInput { + config: Config + triage: FeedbackTriageRow + feedback?: FeedbackRow + error?: ErrorReportRow +} + +// The packet is private (bridge/Lead session), so it may name the reporter and +// the consent flag; the raw message and stack are fetched through the signed +// triage API rather than pasted into a public issue. +export function buildTriagePacket(input: TriagePacketInput): string { + const { config, triage, feedback, error } = input + const token = triageToken(config.sessionSecret, triage.ref) + const summary = redact((feedback?.message ?? error?.error_message ?? '').trim()).slice(0, 500) + const reporter = triage.user_login === null ? 'anonymous' : `@${triage.user_login}` + const lines = [ + '[Fledge feedback triage]', + `ref: ${triage.ref}`, + `kind: ${triage.kind}`, + `reporter: ${reporter}`, + `publishConsent: ${triage.publish_consent === 1 ? 'yes' : 'no'}`, + `publicRepo: ${config.feedback.publicRepo}`, + `summary: ${summary === '' ? '(no summary)' : summary}`, + ...(error === undefined ? [] : [`error: ${error.error_name}: ${error.error_message}`]), + '', + `Read: GET ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}`, + `Authorization: Bearer ${token}`, + `Write: POST ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}/triage`, + 'JSON: {status, user_message, public_summary, issue_url, pr_url, fixed_version}', + 'Statuses: user_error | duplicate | needs_info | confirmed | issue_opened | pr_opened | fixed | wontfix', + 'Rules:', + '- Decide whether this is a user/configuration problem or a platform defect.', + '- A confirmed defect: create one sanitized public issue in publicRepo; include the ref and @reporter only when publishConsent=yes. Never paste the raw message, stack, email or token into the issue.', + '- Record issue_url, pr_url and fixed_version through the Write endpoint.', + '- Fledge sends the in-app/email update to the reporter; do not send it yourself.', + ] + return lines.join('\n').slice(0, 4000) +} + +export async function deliverFeedbackToLead(input: TriagePacketInput & { + instances: LeadTransport | undefined + fetchImpl?: typeof fetch +}): Promise<{ ok: boolean; mode: LeadMode; error?: string }> { + const mode = leadMode(input.config) + if (mode === 'none') return { ok: false, mode, error: 'no Fledge Lead target configured' } + const packet = buildTriagePacket(input) + try { + if (mode === 'repo') { + if (input.instances === undefined) return { ok: false, mode, error: 'instance manager is not available' } + await input.instances.sendGuidance(input.config.feedback.leadRepoId, 'lead', packet) + return { ok: true, mode } + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch + const response = await fetchImpl(`${input.config.feedback.leadBridgeUrl}/dsh`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${input.config.feedback.leadBridgeToken}` }, + body: JSON.stringify({ endpoint: 'send', payload: { sessionId: input.config.feedback.leadSessionId, text: packet } }), + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) return { ok: false, mode, error: `bridge answered ${String(response.status)}` } + return { ok: true, mode } + } catch (error) { + return { ok: false, mode, error: redact(error instanceof Error ? error.message : String(error)).slice(0, 300) } + } +} diff --git a/src/feedback/notify.ts b/src/feedback/notify.ts new file mode 100644 index 00000000..4f05fe5a --- /dev/null +++ b/src/feedback/notify.ts @@ -0,0 +1,81 @@ +// #814: the reporter-facing side of the triage lifecycle. The Lead writes a +// decision through the token-authenticated API; this module delivers the +// in-app/email update and, when the running version catches up with +// `fixed_version`, the final "now live" message once. +import type { Store } from '../db.js' +import type { Mailer } from '../email/mailer.js' +import { notifyEmail } from '../email/recipient.js' +import { feedbackUpdateMail } from '../email/templates.js' +import { compareVersions } from '../releases/release.js' +import { log } from '../lib/log.js' +import { redact } from '../lib/secrets.js' +import { nowIso } from '../lib/time.js' +import { VERSION } from '../version.js' + +export async function notifyFeedbackReporter(input: { + store: Store + mailer: Mailer + ref: string + userId: string | null + status: string + userMessage: string + issueUrl?: string | null + prUrl?: string | null + releasedVersion?: string | null +}): Promise { + if (input.userId === null || input.userMessage.trim() === '') return + input.store.notify(input.userId, 'feedback', input.userMessage.slice(0, 500), `feedback:${input.status}`) + const user = input.store.userById(input.userId) + const to = user === undefined ? '' : notifyEmail(user) + if (to !== '') { + try { + await input.mailer.send({ + to, + ...feedbackUpdateMail({ + ref: input.ref, + status: input.status, + userMessage: input.userMessage, + issueUrl: input.issueUrl ?? null, + prUrl: input.prUrl ?? null, + releasedVersion: input.releasedVersion ?? null, + }), + }) + } catch (error) { + log.warn('feedback update mail failed', { ref: input.ref, error: redact(String(error)) }) + } + } + input.store.updateFeedbackTriage(input.ref, { last_notified_status: input.status, notified_at: nowIso() }) +} + +export async function notifyReleasedFeedback(input: { + store: Store + mailer: Mailer + currentVersion?: string +}): Promise { + const current = input.currentVersion ?? VERSION + let sent = 0 + for (const row of input.store.listFeedbackForRelease(50)) { + if (row.fixed_version === null) continue + try { + if (compareVersions(row.fixed_version, current) > 0) continue + } catch { + continue + } + if (!input.store.markFeedbackReleased(row.ref, current)) continue + const message = `你反馈的问题已在 v${current} 修复并上线。${row.issue_url === null ? '' : ` issue:${row.issue_url}`}${row.pr_url === null ? '' : ` PR:${row.pr_url}`}` + await notifyFeedbackReporter({ + store: input.store, + mailer: input.mailer, + ref: row.ref, + userId: row.user_id, + status: 'released', + userMessage: message, + issueUrl: row.issue_url, + prUrl: row.pr_url, + releasedVersion: current, + }) + input.store.addFeedbackEvent(row.ref, 'released', `v${current}`, 'system') + sent += 1 + } + return sent +} diff --git a/src/views/pages.ts b/src/views/pages.ts index 223c8f80..e9b344be 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -2081,6 +2081,7 @@ export function settingsPage(input: { +

${escapeHtml(t('不要粘贴 token、密码或他人的隐私信息。反馈会保存并邮件通知开发者。', 'Do not paste tokens, passwords or another person’s private data. The note is stored and mailed to the maintainer.'))}

@@ -2552,6 +2553,7 @@ export function errorPage( const reportHtml = report === undefined ? '' : `

${escapeHtml(t('报告只包含错误类型、消息、脱敏后的堆栈、页面、版本和时间;不包含请求体、query、cookie、token 或完整邮箱。', 'The report includes only the error type, message, redacted stack, page, version and time; not the request body, query, cookies, tokens or a full email address.'))}

+ ${viewer === null ? '' : ``}
` return layout({ diff --git a/test/db.test.ts b/test/db.test.ts index a08b73c6..40d07f36 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -151,8 +151,8 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - // #786 moved the build's schema to 41, so the first refused version is 42. - reopened.db.exec('PRAGMA user_version = 42;') + // #814 moved the build's schema to 42, so the first refused version is 43. + reopened.db.exec('PRAGMA user_version = 43;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) diff --git a/test/feedbackLead.test.ts b/test/feedbackLead.test.ts new file mode 100644 index 00000000..eb2fd069 --- /dev/null +++ b/test/feedbackLead.test.ts @@ -0,0 +1,153 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildApp } from '../src/app.js' +import { Store } from '../src/db.js' +import { MockRunner } from '../src/jobs/mock.js' +import { buildTriagePacket, deliverFeedbackToLead, triageToken } from '../src/feedback/lead.js' +import { notifyReleasedFeedback } from '../src/feedback/notify.js' +import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' + +function fixture() { + const store = new Store(':memory:') + const user = makeUser(store, { login: 'reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_lead', userId: user.id, kind: 'feedback', message: 'The schedule page says unknown', page: '/settings' }) + const triage = store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'feedback', publishConsent: true }) + return { store, user, feedback, triage } +} + +test('the packet names the ref, consent and signed triage endpoints', () => { + const f = fixture() + try { + const config = testConfig() + const packet = buildTriagePacket({ config, triage: f.triage, feedback: f.feedback }) + assert.match(packet, /ref: fb_lead/) + assert.match(packet, /publishConsent: yes/) + assert.match(packet, /\/api\/feedback\/fb_lead/) + assert.match(packet, new RegExp(triageToken(config.sessionSecret, 'fb_lead'))) + assert.match(packet, /Never paste the raw message, stack, email or token/) + } finally { + f.store.close() + } +}) + +test('a configured repo target sends through the instance manager', async () => { + const f = fixture() + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: 'repo_fledge' } }) + let captured: { repo: string; target: string; text: string } | undefined + const instances = { + async sendGuidance(repo: string, target: string, text: string) { captured = { repo, target, text } }, + } + const result = await deliverFeedbackToLead({ config, instances, triage: f.triage, feedback: f.feedback }) + assert.deepEqual(result, { ok: true, mode: 'repo' }) + assert.equal(captured?.repo, 'repo_fledge') + assert.equal(captured?.target, 'lead') + assert.match(captured?.text ?? '', /ref: fb_lead/) + } finally { + f.store.close() + } +}) + +test('a configured bridge target posts a send to the configured session', async () => { + const f = fixture() + const server = await startFakeServer(async (request, response) => { + const body = JSON.parse((await readBody(request)).toString('utf8')) as { endpoint: string; payload: { sessionId: string; text: string } } + assert.equal(request.headers.authorization, 'Bearer bridge-token') + assert.equal(body.endpoint, 'send') + assert.equal(body.payload.sessionId, 'lead-session') + assert.match(body.payload.text, /ref: fb_lead/) + json(response, 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + const result = await deliverFeedbackToLead({ config, instances: undefined, triage: f.triage, feedback: f.feedback }) + assert.deepEqual(result, { ok: true, mode: 'bridge' }) + } finally { + f.store.close() + await server.close() + } +}) + +test('the triage API reads and writes with the per-ref token and notifies the reporter', async () => { + const config = testConfig() + const store = new Store(':memory:') + const mailer = new MemoryMailer() + const built = await buildApp({ config, store, mailer, runner: new MockRunner(), startScheduler: false }) + try { + const user = makeUser(store, { login: 'api-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_api', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + const token = triageToken(config.sessionSecret, feedback.id) + const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' } + + const read = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers }) + assert.equal(read.statusCode, 200) + assert.equal((read.json() as { triage: { ref: string } }).triage.ref, 'fb_api') + + const written = await built.app.inject({ + method: 'POST', + url: '/api/feedback/fb_api/triage', + headers, + payload: { status: 'fixed', user_message: 'The schedule fix is ready.', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2', fixed_version: '0.3.6' }, + }) + assert.equal(written.statusCode, 200) + const row = store.feedbackTriageByRef('fb_api') + assert.equal(row?.status, 'fixed') + assert.equal(row?.fixed_version, '0.3.6') + assert.equal(row?.issue_url, 'https://github.com/HugoXK/fledge/issues/1') + assert.equal(store.listFeedbackEvents('fb_api')[0]?.status, 'fixed') + assert.ok(store.listNotifications(user.id).some((n) => n.kind === 'feedback')) + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1) + + const badToken = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers: { authorization: 'Bearer nope' } }) + assert.equal(badToken.statusCode, 401) + const badStatus = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'made_up' } }) + assert.equal(badStatus.statusCode, 400) + } finally { + await built.close() + } +}) + +test('a fixed record is notified once when the running version catches up', async () => { + const store = new Store(':memory:') + const mailer = new MemoryMailer() + try { + const user = makeUser(store, { login: 'release-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_release', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + store.updateFeedbackTriage('fb_release', { status: 'fixed', fixed_version: '0.0.1', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2' }) + + assert.equal(await notifyReleasedFeedback({ store, mailer, currentVersion: '0.3.6' }), 1) + assert.equal(store.feedbackTriageByRef('fb_release')?.status, 'released') + assert.equal(store.feedbackTriageByRef('fb_release')?.released_version, '0.3.6') + assert.ok(store.listNotifications(user.id).some((n) => /0\.3\.6/.test(n.message))) + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1) + assert.equal(await notifyReleasedFeedback({ store, mailer, currentVersion: '0.3.6' }), 0, 'a re-deploy of the same version is silent') + } finally { + store.close() + } +}) + +test('a feedback post with public-issue consent records it on the triage row', async () => { + const config = testConfig() + 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: 'consent-reporter', status: 'approved' }) + const session = await import('./helpers.js').then(({ sessionFor }) => sessionFor(built, user)) + const response = await built.app.inject({ + method: 'POST', + url: '/feedback', + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: session.cookie }, + payload: new URLSearchParams({ csrf: session.csrf, kind: 'feedback', message: 'A real note', publishConsent: '1' }).toString(), + }) + assert.equal(response.statusCode, 302) + const row = store.listFeedback(1)[0] + assert.ok(row) + assert.equal(store.feedbackTriageByRef(row.id)?.publish_consent, 1) + } finally { + await built.close() + } +}) diff --git a/test/scheduleLeases.test.ts b/test/scheduleLeases.test.ts index b8341de4..4dd9249e 100644 --- a/test/scheduleLeases.test.ts +++ b/test/scheduleLeases.test.ts @@ -45,11 +45,11 @@ function claim(h: { store: Store; repoId: string; scheduleId: string }, token: s }) } -test('the occurrence row carries attempts and a lease, at user_version 41', () => { +test('the occurrence row carries attempts and a lease, at user_version 42', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'the feedback/error tables move the refusal boundary from 40 to 41') + assert.equal(Number(version?.user_version), 42, 'the triage tables move the refusal boundary from 41 to 42') const columns = store.all<{ name: string }>('PRAGMA table_info(schedule_occurrences)').map((column) => column.name) for (const column of ['attempts', 'lease_token', 'lease_expires_at']) { diff --git a/test/scheduleOccurrences.test.ts b/test/scheduleOccurrences.test.ts index eead49da..ed039cfa 100644 --- a/test/scheduleOccurrences.test.ts +++ b/test/scheduleOccurrences.test.ts @@ -56,11 +56,11 @@ function audits(store: Store, action: string): string[] { // -- the table ------------------------------------------------------------- -test('the occurrence table is part of the schema at user_version 41', () => { +test('the occurrence table is part of the schema at user_version 42', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'the feedback/error tables move the refusal boundary from 40 to 41') + assert.equal(Number(version?.user_version), 42, 'the triage tables move the refusal boundary from 41 to 42') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedule_occurrences'") assert.equal(table?.name, 'schedule_occurrences') diff --git a/test/scheduleSchema.test.ts b/test/scheduleSchema.test.ts index d67b136c..e501ebbc 100644 --- a/test/scheduleSchema.test.ts +++ b/test/scheduleSchema.test.ts @@ -7,11 +7,11 @@ import { makeUser } from './helpers.js' // the version boundary, the columns the planner reads, and the one-row-per- // repository rule the tick relies on. -test('the schedule table is part of the schema at user_version 41', () => { +test('the schedule table is part of the schema at user_version 42', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 41, 'each schema change moves the refusal boundary: 39 its grace, 40 evolution consent, 41 feedback and error reports') + assert.equal(Number(version?.user_version), 42, 'each schema change moves the refusal boundary: 40 evolution consent, 41 feedback and error reports, 42 triage') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedules'") assert.equal(table?.name, 'schedules') From 8c39ea5b54bb3cba5e5f5104f84bc99592d14f92 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 16:31:02 +0800 Subject: [PATCH 2/4] feat: return a sanitized issue draft and show the reporter their feedback status (#814) --- changelog.d/814-feedback-lead.added.md | 2 +- docs/architecture.md | 5 +-- docs/security.md | 3 +- src/app.ts | 7 ++-- src/db.ts | 4 +++ src/feedback/lead.ts | 14 ++++++++ src/views/pages.ts | 32 +++++++++++++++++-- test/feedbackLead.test.ts | 44 ++++++++++++++++++++++++-- 8 files changed, 101 insertions(+), 10 deletions(-) diff --git a/changelog.d/814-feedback-lead.added.md b/changelog.d/814-feedback-lead.added.md index a837570b..36d79eb6 100644 --- a/changelog.d/814-feedback-lead.added.md +++ b/changelog.d/814-feedback-lead.added.md @@ -1 +1 @@ -Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token lets the Lead read the redacted record and write a user-facing decision back through the triage API; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. A public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. +Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token lets the Lead read the redacted record and write a user-facing decision back through the triage API; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. diff --git a/docs/architecture.md b/docs/architecture.md index 4f6126d4..9d6e85ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,8 +114,9 @@ The feedback lifecycle is one triage row per ref (`feedback_triage`) plus `feedback_events`. New reports are sent to the server FlyBot's Lead through a connected repo (`FLEDGE_LEAD_REPO_ID`) or a direct bridge; the packet carries a per-ref signed token, not the raw stack. The Lead reads and writes through -`GET`/`POST /api/feedback/:ref` and `/triage`; every transition appends an event -and notifies the signed-in reporter in-app and by mail. A `fixed_version` at or +`GET`/`POST /api/feedback/:ref` and `/triage`; the read response carries a +sanitized issue draft, every transition appends an event, and the signed-in +reporter sees in-app/mail updates plus a Settings history row. A `fixed_version` at or below the running `VERSION` sends the fixed notice once; the startup scan then marks the record `released` and sends the “now live” notice once. Fledge never creates the public issue: the Lead does, sanitized, and records its URL. diff --git a/docs/security.md b/docs/security.md index f2d9fb6b..34cb019d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -197,7 +197,8 @@ a bearer capability for that one ref and are rate-limited. The Lead may read the redacted message/stack and write a conclusion, but Fledge does not publish raw feedback or error data. A public GitHub issue is created by the Lead only after a separate sanitized-publication consent, and the issue may @ the reporter only -when that consent is present. The triage row stores the issue/PR URLs, fixed and +when that consent is present. The triage read returns a ready-to-use sanitized +issue draft built from `public_summary`; raw message/stack never enters it. The triage row stores the issue/PR URLs, fixed and released versions, user-facing message, public summary and notification state; the raw text remains in the feedback/error row. diff --git a/src/app.ts b/src/app.ts index 3b530c4f..24401878 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,7 +19,7 @@ import { dedupeRepoNames, instanceLimitRefusal, repoLimitRefusal, type LimitRefu import { isTrialFilter } from './lib/trials.js' import { createMailer, type Mailer } from './email/mailer.js' import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js' -import { deliverFeedbackToLead, verifyTriageToken } from './feedback/lead.js' +import { deliverFeedbackToLead, publicIssueDraft, verifyTriageToken } from './feedback/lead.js' import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js' import { notifyEmail } from './email/recipient.js' import { authenticateApiToken } from './lib/apiToken.js' @@ -3939,6 +3939,7 @@ export async function buildApp(options: BuildOptions): Promise { sessions: store.listSessions(viewer.user.id), currentSessionHash: viewer.sessionHash, providers: store.listModelProviders(viewer.user.id).map((provider) => ({ ...provider, models: store.listProviderModels(provider.id) })), + feedback: store.listFeedbackTriageForUser(viewer.user.id, 10), discoveredProvider: queryText(request, 'discoveredProvider'), discovered: (queryText(request, 'discovered') ?? '').split(',').filter((value) => value !== ''), notice: request.flash?.notice?.text, @@ -4249,6 +4250,7 @@ export async function buildApp(options: BuildOptions): Promise { feedback: store.feedbackById(ref) ?? null, error: store.errorReportByRef(ref) ?? null, events: store.listFeedbackEvents(ref), + issueDraft: publicIssueDraft(triage), } }) @@ -4298,7 +4300,8 @@ export async function buildApp(options: BuildOptions): Promise { issueUrl: issueUrl === '' ? null : issueUrl, prUrl: prUrl === '' ? null : prUrl, }) - return { ok: true, status } + const updated = store.feedbackTriageByRef(ref) + return { ok: true, status, issueDraft: updated === undefined ? null : publicIssueDraft(updated) } }) app.post('/settings/tokens', { config: { rateLimit: destructiveLimit } }, async (request, reply) => { diff --git a/src/db.ts b/src/db.ts index 9f4d2c23..8deec308 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5792,6 +5792,10 @@ export class Store { return this.all('SELECT * FROM feedback_events WHERE ref = ? ORDER BY created_at DESC, rowid DESC LIMIT ?', ref, Math.max(1, Math.min(100, limit))) } + listFeedbackTriageForUser(userId: string, limit = 10): FeedbackTriageRow[] { + return this.all('SELECT * FROM feedback_triage WHERE user_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT ?', userId, Math.max(1, Math.min(50, limit))) + } + listFeedbackForRelease(limit = 50): FeedbackTriageRow[] { return this.all("SELECT * FROM feedback_triage WHERE fixed_version IS NOT NULL AND released_version IS NULL AND user_id IS NOT NULL AND status IN ('fixed','pr_opened') ORDER BY created_at ASC LIMIT ?", Math.max(1, Math.min(100, limit))) } diff --git a/src/feedback/lead.ts b/src/feedback/lead.ts index de9fd9c3..65dad925 100644 --- a/src/feedback/lead.ts +++ b/src/feedback/lead.ts @@ -28,6 +28,19 @@ export function verifyTriageToken(sessionSecret: string, ref: string, supplied: return supplied !== '' && safeEqual(supplied, triageToken(sessionSecret, ref)) } +// #814: the only text a Lead should paste into a public issue. It is derived +// from the Lead's own public_summary, never from the raw message or stack, and +// the @mention appears only when the reporter explicitly consented. +export function publicIssueDraft(triage: FeedbackTriageRow): { title: string; body: string; mention: string } { + const summary = (triage.public_summary ?? '').trim() || 'A Fledge user reported a problem.' + const mention = triage.publish_consent === 1 && triage.user_login !== null ? `@${triage.user_login}` : '' + return { + title: `[feedback] ${summary.slice(0, 80)}`.slice(0, 120), + body: `${mention === '' ? 'Thanks for the report' : `${mention} thanks for the report`} (ref \`${triage.ref}\`).\n\n${summary}\n\nFledge will update this issue when a fix ships.`, + mention, + } +} + export interface TriagePacketInput { config: Config triage: FeedbackTriageRow @@ -62,6 +75,7 @@ export function buildTriagePacket(input: TriagePacketInput): string { '- Decide whether this is a user/configuration problem or a platform defect.', '- A confirmed defect: create one sanitized public issue in publicRepo; include the ref and @reporter only when publishConsent=yes. Never paste the raw message, stack, email or token into the issue.', '- Record issue_url, pr_url and fixed_version through the Write endpoint.', + '- After setting public_summary, GET the record again for a ready-to-use sanitized issue draft.', '- Fledge sends the in-app/email update to the reporter; do not send it yourself.', ] return lines.join('\n').slice(0, 4000) diff --git a/src/views/pages.ts b/src/views/pages.ts index e9b344be..3fd33253 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -1,6 +1,6 @@ import type { Config } from '../config.js' import type { CostReport } from '../metrics/cost.js' -import type { AlertView, AuditRow, DeploymentRollbackRow, DeploymentRow, JobEventRow, JobRow, ModelProviderRow, NotificationRow, OwnedSearch, PostmortemRecurrenceRow, PostmortemRow, ProviderModelRow, ReleaseRow, RepoInstanceRow, RepoRow, ReviewTrendWindow, SessionRow, UserRow, ApiTokenRow, WorkflowFailureRow, WorkItemRow, TaskRelationRow } from '../db.js' +import type { AlertView, AuditRow, DeploymentRollbackRow, DeploymentRow, FeedbackTriageRow, JobEventRow, JobRow, ModelProviderRow, NotificationRow, OwnedSearch, PostmortemRecurrenceRow, PostmortemRow, ProviderModelRow, ReleaseRow, RepoInstanceRow, RepoRow, ReviewTrendWindow, SessionRow, UserRow, ApiTokenRow, WorkflowFailureRow, WorkItemRow, TaskRelationRow } from '../db.js' import { assetUrl } from '../lib/assetVersion.js' import { redactCommand, type DriftAssessment, type EnvironmentCurrent, type ReleaseSnapshot } from '../lib/deployments.js' import { agreementContent } from '../users/agreements.js' @@ -1869,6 +1869,23 @@ export function teamPage(input: { return layout({ title: t('团队工作台', 'Team console'), viewer, body, config, locale, path: `/repos/${repo.id}/team`, active: 'dashboard', notice, error }) } +function feedbackStatusLabel(t: Translate, status: string): string { + const labels: Record = { + new: ['已收到', 'Received'], + user_error: ['使用问题', 'User error'], + duplicate: ['重复', 'Duplicate'], + needs_info: ['需要补充信息', 'Needs info'], + confirmed: ['已确认', 'Confirmed'], + issue_opened: ['已建 issue', 'Issue opened'], + pr_opened: ['修复中', 'Fix in review'], + fixed: ['已修复', 'Fixed'], + released: ['已上线', 'Released'], + wontfix: ['不修复', 'Won\'t fix'], + } + const label = labels[status] + return label === undefined ? status : t(label[0], label[1]) +} + export function settingsPage(input: { config: Config locale: Locale @@ -1877,13 +1894,14 @@ export function settingsPage(input: { sessions: SessionRow[] currentSessionHash: string providers: Array + feedback?: FeedbackTriageRow[] discoveredProvider?: string discovered?: string[] newToken?: string notice?: string error?: string }): string { - const { config, locale, viewer, tokens, sessions, currentSessionHash, providers, discoveredProvider, discovered = [], newToken, notice, error } = input + const { config, locale, viewer, tokens, sessions, currentSessionHash, providers, feedback = [], discoveredProvider, discovered = [], newToken, notice, error } = input const t = translator(locale) const user = viewer.user const quota = quotaView(user, config) @@ -2085,6 +2103,16 @@ export function settingsPage(input: {

${escapeHtml(t('不要粘贴 token、密码或他人的隐私信息。反馈会保存并邮件通知开发者。', 'Do not paste tokens, passwords or another person’s private data. The note is stored and mailed to the maintainer.'))}

+ ${feedback.length === 0 ? '' : `
+ ${feedback.map((row) => { + const links = [ + row.issue_url === null ? '' : `Issue`, + row.pr_url === null ? '' : `PR`, + ].filter((value) => value !== '').join(' · ') + const version = row.released_version ?? row.fixed_version + return `
#${escapeHtml(row.ref)} · ${escapeHtml(feedbackStatusLabel(t, row.status))}${escapeHtml(row.kind)}${links === '' ? '' : ` · ${links}`}${version === null ? '' : ` · v${escapeHtml(version)}`} · ${escapeHtml(humanTimeShort(row.updated_at))}
` + }).join('')} +
`}
diff --git a/test/feedbackLead.test.ts b/test/feedbackLead.test.ts index eb2fd069..1a7c604a 100644 --- a/test/feedbackLead.test.ts +++ b/test/feedbackLead.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { buildApp } from '../src/app.js' import { Store } from '../src/db.js' import { MockRunner } from '../src/jobs/mock.js' -import { buildTriagePacket, deliverFeedbackToLead, triageToken } from '../src/feedback/lead.js' +import { buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, triageToken } from '../src/feedback/lead.js' import { notifyReleasedFeedback } from '../src/feedback/notify.js' import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' @@ -30,6 +30,21 @@ test('the packet names the ref, consent and signed triage endpoints', () => { } }) + +test('the public issue draft mentions the reporter only with publication consent', () => { + const f = fixture() + try { + const draft = publicIssueDraft(f.triage) + assert.match(draft.body, /@reporter/) + assert.match(draft.body, /fb_lead/) + const noConsent = publicIssueDraft({ ...f.triage, publish_consent: 0 }) + assert.doesNotMatch(noConsent.body, /@reporter/) + assert.equal(noConsent.mention, '') + } finally { + f.store.close() + } +}) + test('a configured repo target sends through the instance manager', async () => { const f = fixture() try { @@ -84,7 +99,9 @@ test('the triage API reads and writes with the per-ref token and notifies the re const read = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers }) assert.equal(read.statusCode, 200) - assert.equal((read.json() as { triage: { ref: string } }).triage.ref, 'fb_api') + const readBodyJson = read.json() as { triage: { ref: string }, issueDraft: { body: string } } + assert.equal(readBodyJson.triage.ref, 'fb_api') + assert.match(readBodyJson.issueDraft.body, /fb_api/) const written = await built.app.inject({ method: 'POST', @@ -151,3 +168,26 @@ test('a feedback post with public-issue consent records it on the triage row', a await built.close() } }) + + +test('settings shows the reporter own feedback status, links and version', async () => { + const config = testConfig() + 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: 'history-reporter', status: 'approved' }) + const feedback = store.createFeedback({ id: 'fb_history', userId: user.id, kind: 'bug', message: 'boom', page: '/dashboard' }) + store.createFeedbackTriage({ ref: feedback.id, userId: user.id, userLogin: user.login, kind: 'bug', publishConsent: true }) + store.updateFeedbackTriage('fb_history', { status: 'fixed', fixed_version: '0.3.6', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2' }) + const session = await import('./helpers.js').then(({ sessionFor }) => sessionFor(built, user)) + const page = await built.app.inject({ method: 'GET', url: '/settings', headers: { cookie: `${session.cookie}; fledge_lang=en` } }) + assert.equal(page.statusCode, 200) + assert.match(page.body, /data-feedback-history/) + assert.match(page.body, /fb_history/) + assert.match(page.body, /Fixed/) + assert.match(page.body, /issues\/1/) + assert.match(page.body, /pull\/2/) + } finally { + await built.close() + } +}) From 4e4c19a18205527510a83653af4192a8a6b884db Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:19:14 +0800 Subject: [PATCH 3/4] fix: validate the triage decision, expire its token and make a retry a no-op (#814) --- changelog.d/814-feedback-lead.added.md | 2 +- docs/security.md | 5 +- src/app.ts | 24 +++++----- src/feedback/lead.ts | 17 +++++-- src/feedback/lifecycle.ts | 65 ++++++++++++++++++++++++++ test/feedbackLead.test.ts | 28 ++++++++++- 6 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 src/feedback/lifecycle.ts diff --git a/changelog.d/814-feedback-lead.added.md b/changelog.d/814-feedback-lead.added.md index 36d79eb6..86863bd0 100644 --- a/changelog.d/814-feedback-lead.added.md +++ b/changelog.d/814-feedback-lead.added.md @@ -1 +1 @@ -Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token lets the Lead read the redacted record and write a user-facing decision back through the triage API; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. +Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token, expiring after seven days, lets the Lead read the redacted record and write a user-facing decision back through the triage API; statuses carry their required message/issue/PR/version fields and a retry is a no-op; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. diff --git a/docs/security.md b/docs/security.md index 34cb019d..3f0df914 100644 --- a/docs/security.md +++ b/docs/security.md @@ -193,7 +193,10 @@ refusal is user-caused and never offers the button. Each feedback/error record gets a per-ref triage token derived from `HMAC(SESSION_SECRET, "feedback-triage:")`. The token is delivered only in the private packet to the Fledge Lead; the read and write endpoints accept it as -a bearer capability for that one ref and are rate-limited. The Lead may read the +a bearer capability for that one ref, it expires after seven days, and both are +rate-limited. Each status has required fields (a user message, an issue URL, a +pull URL, a fixed version), public links must be issues/pulls in +`FEEDBACK_PUBLIC_REPO`, and a replay of the same decision is a no-op. The Lead may read the redacted message/stack and write a conclusion, but Fledge does not publish raw feedback or error data. A public GitHub issue is created by the Lead only after a separate sanitized-publication consent, and the issue may @ the reporter only diff --git a/src/app.ts b/src/app.ts index 24401878..e638120c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ import { isTrialFilter } from './lib/trials.js' import { createMailer, type Mailer } from './email/mailer.js' import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js' import { deliverFeedbackToLead, publicIssueDraft, verifyTriageToken } from './feedback/lead.js' +import { triageProblems } from './feedback/lifecycle.js' import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js' import { notifyEmail } from './email/recipient.js' import { authenticateApiToken } from './lib/apiToken.js' @@ -4261,25 +4262,24 @@ export async function buildApp(options: BuildOptions): Promise { const triage = store.feedbackTriageByRef(ref) if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } }) const body = (request.body ?? {}) as Record - const allowed = new Set(['user_error', 'duplicate', 'needs_info', 'confirmed', 'issue_opened', 'pr_opened', 'fixed', 'wontfix']) const status = typeof body.status === 'string' ? body.status.trim() : '' - if (!allowed.has(status)) return reply.code(400).send({ error: { type: 'invalid_status', message: 'Unsupported triage status' } }) const userMessage = redact(String(body.user_message ?? '')).trim().slice(0, 1000) const publicSummary = redact(String(body.public_summary ?? '')).trim().slice(0, 1000) const issueUrl = String(body.issue_url ?? '').trim() const prUrl = String(body.pr_url ?? '').trim() const fixedVersion = String(body.fixed_version ?? '').trim() - const isGithubUrl = (value: string): boolean => { - if (value === '') return true - try { - const url = new URL(value) - return url.protocol === 'https:' && url.hostname === 'github.com' - } catch { - return false - } - } - if (!isGithubUrl(issueUrl) || !isGithubUrl(prUrl)) return reply.code(400).send({ error: { type: 'invalid_url', message: 'Only https://github.com links are accepted' } }) + const problems = triageProblems({ status, userMessage, publicSummary, issueUrl, prUrl, fixedVersion, publicRepo: config.feedback.publicRepo }) + if (problems.length > 0) return reply.code(400).send({ error: { type: 'invalid_triage', message: problems.join('; ') } }) if (fixedVersion !== '' && parseVersion(fixedVersion) === null) return reply.code(400).send({ error: { type: 'invalid_version', message: 'fixed_version must be SemVer' } }) + const unchanged = triage.status === status && + (triage.user_message ?? '') === userMessage && + (triage.public_summary ?? '') === publicSummary && + (triage.issue_url ?? '') === issueUrl && + (triage.pr_url ?? '') === prUrl && + (triage.fixed_version ?? '') === fixedVersion + if (unchanged) { + return { ok: true, status, unchanged: true, issueDraft: publicIssueDraft(triage) } + } store.updateFeedbackTriage(ref, { status, user_message: userMessage === '' ? null : userMessage, diff --git a/src/feedback/lead.ts b/src/feedback/lead.ts index 65dad925..d547a8b4 100644 --- a/src/feedback/lead.ts +++ b/src/feedback/lead.ts @@ -20,12 +20,17 @@ export function leadMode(config: Config): LeadMode { return 'none' } -export function triageToken(sessionSecret: string, ref: string): string { - return hmacHex(sessionSecret, `feedback-triage:${ref}`) +export const TRIAGE_TOKEN_TTL_MS = 7 * 86_400_000 + +export function triageToken(sessionSecret: string, ref: string, expiresAt = Date.now() + TRIAGE_TOKEN_TTL_MS): string { + return `${expiresAt}.${hmacHex(sessionSecret, `feedback-triage:${ref}:${expiresAt}`)}` } -export function verifyTriageToken(sessionSecret: string, ref: string, supplied: string): boolean { - return supplied !== '' && safeEqual(supplied, triageToken(sessionSecret, ref)) +export function verifyTriageToken(sessionSecret: string, ref: string, supplied: string, now = Date.now()): boolean { + const [expires, signature] = supplied.split('.', 2) + const at = Number(expires) + if (!Number.isFinite(at) || at <= now || signature === undefined) return false + return safeEqual(signature, hmacHex(sessionSecret, `feedback-triage:${ref}:${expires}`)) } // #814: the only text a Lead should paste into a public issue. It is derived @@ -53,7 +58,8 @@ export interface TriagePacketInput { // triage API rather than pasted into a public issue. export function buildTriagePacket(input: TriagePacketInput): string { const { config, triage, feedback, error } = input - const token = triageToken(config.sessionSecret, triage.ref) + const tokenExpiresAt = Date.now() + TRIAGE_TOKEN_TTL_MS + const token = triageToken(config.sessionSecret, triage.ref, tokenExpiresAt) const summary = redact((feedback?.message ?? error?.error_message ?? '').trim()).slice(0, 500) const reporter = triage.user_login === null ? 'anonymous' : `@${triage.user_login}` const lines = [ @@ -68,6 +74,7 @@ export function buildTriagePacket(input: TriagePacketInput): string { '', `Read: GET ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}`, `Authorization: Bearer ${token}`, + `Token expires: ${new Date(tokenExpiresAt).toISOString()}`, `Write: POST ${config.baseUrl}/api/feedback/${encodeURIComponent(triage.ref)}/triage`, 'JSON: {status, user_message, public_summary, issue_url, pr_url, fixed_version}', 'Statuses: user_error | duplicate | needs_info | confirmed | issue_opened | pr_opened | fixed | wontfix', diff --git a/src/feedback/lifecycle.ts b/src/feedback/lifecycle.ts new file mode 100644 index 00000000..7cb298c5 --- /dev/null +++ b/src/feedback/lifecycle.ts @@ -0,0 +1,65 @@ +// #814: the Lead writes a decision, not a free-form status. This module keeps +// the accepted statuses, the fields each one needs and the public GitHub URL +// scope in one place, so the API and its tests cannot drift. + +export const TRIAGE_STATUSES = [ + 'user_error', + 'duplicate', + 'needs_info', + 'confirmed', + 'issue_opened', + 'pr_opened', + 'fixed', + 'wontfix', +] as const + +export type TriageStatus = (typeof TRIAGE_STATUSES)[number] + +export interface TriageDecision { + status: string + userMessage: string + publicSummary: string + issueUrl: string + prUrl: string + fixedVersion: string +} + +export function isTriageStatus(value: string): value is TriageStatus { + return (TRIAGE_STATUSES as readonly string[]).includes(value) +} + +// A public issue/PR link must be in the configured Fledge repository and be the +// right object kind. A link to another repository, a profile or a comment is a +// mistake the Lead should fix before Fledge records it. +export function publicGithubObject(value: string, kind: 'issues' | 'pull', publicRepo: string): boolean { + if (value === '') return true + const [owner, name] = publicRepo.split('/') + if (owner === undefined || owner === '' || name === undefined || name === '') return false + try { + const url = new URL(value) + if (url.protocol !== 'https:' || url.hostname !== 'github.com') return false + return new RegExp(`^/${escapeRegex(owner)}/${escapeRegex(name)}/${kind}/\\d+/?$`).test(url.pathname) + } catch { + return false + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Every user-facing status needs a message. A fixed result and a public issue +// additionally need the link/version that makes the next notification honest. +export function triageProblems(input: TriageDecision & { publicRepo: string }): string[] { + const problems: string[] = [] + if (input.status === 'new' || input.status === 'released') problems.push('status is system-managed and cannot be written by the Lead') + else if (!isTriageStatus(input.status)) problems.push('unsupported status') + if (input.status !== 'new' && input.status !== 'released' && input.userMessage.trim() === '') problems.push('user_message is required for a user-facing status') + if ((input.status === 'duplicate' || input.status === 'issue_opened') && input.issueUrl === '') problems.push('issue_url is required for this status') + if ((input.status === 'pr_opened' || input.status === 'fixed') && input.prUrl === '') problems.push('pr_url is required for this status') + if ((input.status === 'confirmed' || input.status === 'issue_opened') && input.publicSummary === '') problems.push('public_summary is required for a confirmed or opened issue') + if (input.status === 'fixed' && input.fixedVersion === '') problems.push('fixed_version is required for a fixed status') + if (!publicGithubObject(input.issueUrl, 'issues', input.publicRepo)) problems.push(`issue_url must be an issue in https://github.com/${input.publicRepo}`) + if (!publicGithubObject(input.prUrl, 'pull', input.publicRepo)) problems.push(`pr_url must be a pull request in https://github.com/${input.publicRepo}`) + return problems +} diff --git a/test/feedbackLead.test.ts b/test/feedbackLead.test.ts index 1a7c604a..c0ea1889 100644 --- a/test/feedbackLead.test.ts +++ b/test/feedbackLead.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { buildApp } from '../src/app.js' import { Store } from '../src/db.js' import { MockRunner } from '../src/jobs/mock.js' -import { buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, triageToken } from '../src/feedback/lead.js' +import { buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, triageToken, verifyTriageToken } from '../src/feedback/lead.js' import { notifyReleasedFeedback } from '../src/feedback/notify.js' import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' @@ -23,7 +23,8 @@ test('the packet names the ref, consent and signed triage endpoints', () => { assert.match(packet, /ref: fb_lead/) assert.match(packet, /publishConsent: yes/) assert.match(packet, /\/api\/feedback\/fb_lead/) - assert.match(packet, new RegExp(triageToken(config.sessionSecret, 'fb_lead'))) + const token = packet.split('Bearer ')[1]?.split('\n')[0] ?? '' + assert.equal(verifyTriageToken(config.sessionSecret, 'fb_lead', token), true, 'the packet carries a live token for this ref') assert.match(packet, /Never paste the raw message, stack, email or token/) } finally { f.store.close() @@ -118,8 +119,31 @@ test('the triage API reads and writes with the per-ref token and notifies the re assert.ok(store.listNotifications(user.id).some((n) => n.kind === 'feedback')) assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1) + const replay = await built.app.inject({ + method: 'POST', + url: '/api/feedback/fb_api/triage', + headers, + payload: { status: 'fixed', user_message: 'The schedule fix is ready.', issue_url: 'https://github.com/HugoXK/fledge/issues/1', pr_url: 'https://github.com/HugoXK/fledge/pull/2', fixed_version: '0.3.6' }, + }) + assert.equal(replay.statusCode, 200) + assert.equal((replay.json() as { unchanged: boolean }).unchanged, true) + assert.equal(store.listFeedbackEvents('fb_api').length, 1, 'a retry does not append a second event') + assert.equal(mailer.messages.filter((m) => /反馈更新/.test(m.subject)).length, 1, 'a retry does not mail twice') + + const missingVersion = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'fixed', user_message: 'x', pr_url: 'https://github.com/HugoXK/fledge/pull/2' } }) + assert.equal(missingVersion.statusCode, 400) + const missingIssue = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'duplicate', user_message: 'x' } }) + assert.equal(missingIssue.statusCode, 400) + const outsideRepo = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'confirmed', user_message: 'x', public_summary: 'x', issue_url: 'https://github.com/other/repo/issues/1' } }) + assert.equal(outsideRepo.statusCode, 400) + const systemStatus = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'released', user_message: 'x' } }) + assert.equal(systemStatus.statusCode, 400) + const badToken = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers: { authorization: 'Bearer nope' } }) assert.equal(badToken.statusCode, 401) + const expiredToken = triageToken(config.sessionSecret, 'fb_api', Date.now() - 1_000) + const expired = await built.app.inject({ method: 'GET', url: '/api/feedback/fb_api', headers: { authorization: `Bearer ${expiredToken}` } }) + assert.equal(expired.statusCode, 401) const badStatus = await built.app.inject({ method: 'POST', url: '/api/feedback/fb_api/triage', headers, payload: { status: 'made_up' } }) assert.equal(badStatus.statusCode, 400) } finally { From 52edfe27d6367dfac1188911136a3821cecbb356 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:27:44 +0800 Subject: [PATCH 4/4] fix: retry a failed Lead delivery with a bounded outbox (#814) --- changelog.d/814-feedback-lead.added.md | 2 +- docs/architecture.md | 4 +- docs/security.md | 3 +- src/app.ts | 13 ++++-- src/db.ts | 38 ++++++++++++++-- src/feedback/lead.ts | 60 +++++++++++++++++++++++++- test/db.test.ts | 4 +- test/feedbackLead.test.ts | 54 ++++++++++++++++++++++- test/scheduleLeases.test.ts | 4 +- test/scheduleOccurrences.test.ts | 4 +- test/scheduleSchema.test.ts | 4 +- 11 files changed, 171 insertions(+), 19 deletions(-) diff --git a/changelog.d/814-feedback-lead.added.md b/changelog.d/814-feedback-lead.added.md index 86863bd0..64970a0c 100644 --- a/changelog.d/814-feedback-lead.added.md +++ b/changelog.d/814-feedback-lead.added.md @@ -1 +1 @@ -Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token, expiring after seven days, lets the Lead read the redacted record and write a user-facing decision back through the triage API; statuses carry their required message/issue/PR/version fields and a retry is a no-op; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. +Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token, expiring after seven days, with a bounded five-attempt delivery outbox, lets the Lead read the redacted record and write a user-facing decision back through the triage API; statuses carry their required message/issue/PR/version fields and a retry is a no-op; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version. diff --git a/docs/architecture.md b/docs/architecture.md index 9d6e85ac..a15d1837 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,9 @@ connected repo (`FLEDGE_LEAD_REPO_ID`) or a direct bridge; the packet carries a per-ref signed token, not the raw stack. The Lead reads and writes through `GET`/`POST /api/feedback/:ref` and `/triage`; the read response carries a sanitized issue draft, every transition appends an event, and the signed-in -reporter sees in-app/mail updates plus a Settings history row. A `fixed_version` at or +reporter sees in-app/mail updates plus a Settings history row. Delivery is a +bounded outbox: a failure is retried with backoff up to five attempts and the +row records the last error/next attempt; a delivered row is never resent. A `fixed_version` at or below the running `VERSION` sends the fixed notice once; the startup scan then marks the record `released` and sends the “now live” notice once. Fledge never creates the public issue: the Lead does, sanitized, and records its URL. diff --git a/docs/security.md b/docs/security.md index 3f0df914..16510383 100644 --- a/docs/security.md +++ b/docs/security.md @@ -202,7 +202,8 @@ feedback or error data. A public GitHub issue is created by the Lead only after separate sanitized-publication consent, and the issue may @ the reporter only when that consent is present. The triage read returns a ready-to-use sanitized issue draft built from `public_summary`; raw message/stack never enters it. The triage row stores the issue/PR URLs, fixed and -released versions, user-facing message, public summary and notification state; +released versions, user-facing message, public summary, notification state and +the bounded Lead-delivery attempts; the raw text remains in the feedback/error row. ### CSRF diff --git a/src/app.ts b/src/app.ts index e638120c..832dcd7e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -19,7 +19,7 @@ import { dedupeRepoNames, instanceLimitRefusal, repoLimitRefusal, type LimitRefu import { isTrialFilter } from './lib/trials.js' import { createMailer, type Mailer } from './email/mailer.js' import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js' -import { deliverFeedbackToLead, publicIssueDraft, verifyTriageToken } from './feedback/lead.js' +import { attemptLeadDelivery, publicIssueDraft, retryPendingLeadDeliveries, verifyTriageToken } from './feedback/lead.js' import { triageProblems } from './feedback/lifecycle.js' import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js' import { notifyEmail } from './email/recipient.js' @@ -4161,7 +4161,7 @@ export async function buildApp(options: BuildOptions): Promise { const publishConsent = body.publishConsent === '1' store.createFeedback({ id, userId: viewer.user.id, kind, message, page, createdAt }) const triage = store.createFeedbackTriage({ ref: id, userId: viewer.user.id, userLogin: viewer.user.login, kind, publishConsent, createdAt }) - void deliverFeedbackToLead({ config, instances, triage, feedback: store.feedbackById(id) }).catch((error) => log.warn('feedback lead delivery failed', { ref: id, error: redact(String(error)) })) + void attemptLeadDelivery({ store, config, instances, triage, feedback: store.feedbackById(id) }).catch((error) => log.warn('feedback lead delivery failed', { ref: id, error: redact(String(error)) })) store.audit(viewer.user.id, 'feedback.created', `${id} kind=${kind} chars=${message.length}`) const replyTo = notifyEmail(viewer.user) try { @@ -4207,7 +4207,7 @@ export async function buildApp(options: BuildOptions): Promise { publishConsent, createdAt: report.created_at, }) - void deliverFeedbackToLead({ config, instances, triage, error: report }).catch((error) => log.warn('error report lead delivery failed', { ref, error: redact(String(error)) })) + void attemptLeadDelivery({ store, config, instances, triage, error: report }).catch((error) => log.warn('error report lead delivery failed', { ref, error: redact(String(error)) })) try { await mailer.send({ to: config.mail.feedbackEmail, @@ -5663,16 +5663,23 @@ export async function buildApp(options: BuildOptions): Promise { return htmlPage(reply, status, errorPage(config, request.viewer === undefined ? null : viewOf(request.viewer), status === 404 ? '页面不存在' : '出错了', status >= 500 ? '服务器出了点问题,请稍后再试。' : message, request.locale, undefined, report)) }) + let retryingLead = false const sweeper = setInterval(() => { limits.sweep() apiLimiter.sweep() store.deleteExpiredSessions() + if (options.startScheduler === false || retryingLead) return + retryingLead = true + void retryPendingLeadDeliveries({ store, config, instances }) + .catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) })) + .finally(() => { retryingLead = false }) }, 60_000) sweeper.unref() if (options.startScheduler !== false) { jobs.start() void notifyReleasedFeedback({ store, mailer }).catch((error) => log.warn('released feedback notification failed', { error: redact(String(error)) })) + void retryPendingLeadDeliveries({ store, config, instances }).catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) })) } return { diff --git a/src/db.ts b/src/db.ts index 8deec308..55565043 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1921,6 +1921,10 @@ CREATE TABLE IF NOT EXISTS feedback_triage ( released_at TEXT, last_notified_status TEXT, notified_at TEXT, + lead_delivered_at TEXT, + lead_attempts INTEGER NOT NULL DEFAULT 0, + lead_last_error TEXT, + lead_next_attempt_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -2066,6 +2070,10 @@ export interface FeedbackTriageRow { released_at: string | null last_notified_status: string | null notified_at: string | null + lead_delivered_at: string | null + lead_attempts: number + lead_last_error: string | null + lead_next_attempt_at: string | null created_at: string updated_at: string } @@ -2089,11 +2097,12 @@ export class Store { this.db.exec('PRAGMA foreign_keys = ON;') this.db.exec('PRAGMA busy_timeout = 5000;') const previousVersion = Number(this.get<{ user_version: number }>('PRAGMA user_version')?.user_version ?? 0) - if (previousVersion > 42) { + if (previousVersion > 43) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (42)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (43)`) } this.db.exec(SCHEMA) + this.migrateFeedbackTriageColumns() this.migrateColumns() this.migrateRepoColumns() this.migrateProviderColumns() @@ -2114,7 +2123,7 @@ export class Store { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } this.migrateIdentity() - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 42)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 43)};`) } // #544 slice 3B-B2: a database written by slice B1 already has the occurrence @@ -2323,6 +2332,16 @@ export class Store { } } + // #814 follow-up: the triage row existed before the Lead delivery outbox, so + // an older database gains the retry columns in place. + private migrateFeedbackTriageColumns(): void { + const columns = this.all<{ name: string }>('PRAGMA table_info(feedback_triage)').map((column) => column.name) + if (!columns.includes('lead_delivered_at')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_delivered_at TEXT') + if (!columns.includes('lead_attempts')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_attempts INTEGER NOT NULL DEFAULT 0') + if (!columns.includes('lead_last_error')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_last_error TEXT') + if (!columns.includes('lead_next_attempt_at')) this.db.exec('ALTER TABLE feedback_triage ADD COLUMN lead_next_attempt_at TEXT') + } + private migrateColumns(): void { const columns = new Set(this.all<{ name: string }>('PRAGMA table_info(users)').map((row) => row.name)) const additions: Array<[string, string]> = [ @@ -5796,6 +5815,19 @@ export class Store { return this.all('SELECT * FROM feedback_triage WHERE user_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT ?', userId, Math.max(1, Math.min(50, limit))) } + markLeadDelivered(ref: string): void { + const at = nowIso() + this.run('UPDATE feedback_triage SET lead_delivered_at = ?, lead_last_error = NULL, lead_next_attempt_at = NULL, updated_at = ? WHERE ref = ?', at, at, ref) + } + + recordLeadDeliveryFailure(ref: string, error: string, attempts: number, nextAt: string | null): void { + this.run('UPDATE feedback_triage SET lead_attempts = ?, lead_last_error = ?, lead_next_attempt_at = ?, updated_at = ? WHERE ref = ?', attempts, error.slice(0, 500), nextAt, nowIso(), ref) + } + + listPendingLeadDeliveries(now: string, limit = 20): FeedbackTriageRow[] { + return this.all('SELECT * FROM feedback_triage WHERE lead_delivered_at IS NULL AND lead_attempts < 5 AND (lead_next_attempt_at IS NULL OR lead_next_attempt_at <= ?) ORDER BY created_at ASC LIMIT ?', now, Math.max(1, Math.min(100, limit))) + } + listFeedbackForRelease(limit = 50): FeedbackTriageRow[] { return this.all("SELECT * FROM feedback_triage WHERE fixed_version IS NOT NULL AND released_version IS NULL AND user_id IS NOT NULL AND status IN ('fixed','pr_opened') ORDER BY created_at ASC LIMIT ?", Math.max(1, Math.min(100, limit))) } diff --git a/src/feedback/lead.ts b/src/feedback/lead.ts index d547a8b4..7f29cdb8 100644 --- a/src/feedback/lead.ts +++ b/src/feedback/lead.ts @@ -5,7 +5,7 @@ // The report itself must never fail because the Lead is offline; the caller // keeps the row and the maintainer mail. import type { Config } from '../config.js' -import type { ErrorReportRow, FeedbackRow, FeedbackTriageRow } from '../db.js' +import type { ErrorReportRow, FeedbackRow, FeedbackTriageRow, Store } from '../db.js' import { hmacHex, redact, safeEqual } from '../lib/secrets.js' export interface LeadTransport { @@ -88,6 +88,64 @@ export function buildTriagePacket(input: TriagePacketInput): string { return lines.join('\n').slice(0, 4000) } +export const LEAD_MAX_ATTEMPTS = 5 + +export function leadRetryDelayMs(attempts: number): number { + const delays = [60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000, 6 * 60 * 60_000] + return delays[Math.max(0, Math.min(delays.length - 1, attempts - 1))]! +} + +// #814 follow-up: the route records every attempt, so a temporary bridge +// outage is retried with a bounded backoff instead of silently disappearing. +// The packet is idempotent by ref, so a resend after a lost DB write is safe. +export async function attemptLeadDelivery(input: TriagePacketInput & { + store: Store + instances: LeadTransport | undefined + fetchImpl?: typeof fetch + now?: number +}): Promise<{ ok: boolean; mode: LeadMode; error?: string }> { + const result = await deliverFeedbackToLead(input) + if (result.ok) { + input.store.markLeadDelivered(input.triage.ref) + return result + } + const attempts = input.triage.lead_attempts + 1 + const next = attempts >= LEAD_MAX_ATTEMPTS + ? null + : new Date((input.now ?? Date.now()) + leadRetryDelayMs(attempts)).toISOString() + input.store.recordLeadDeliveryFailure(input.triage.ref, result.error ?? 'unknown', attempts, next) + return result +} + +export async function retryPendingLeadDeliveries(input: { + store: Store + config: Config + instances: LeadTransport | undefined + fetchImpl?: typeof fetch + now?: number + limit?: number +}): Promise { + const now = input.now ?? Date.now() + const pending = input.store.listPendingLeadDeliveries(new Date(now).toISOString(), input.limit ?? 20) + let sent = 0 + for (const triage of pending) { + const feedback = input.store.feedbackById(triage.ref) + const error = input.store.errorReportByRef(triage.ref) + const result = await attemptLeadDelivery({ + store: input.store, + config: input.config, + instances: input.instances, + triage, + ...(feedback === undefined ? {} : { feedback }), + ...(error === undefined ? {} : { error }), + ...(input.fetchImpl === undefined ? {} : { fetchImpl: input.fetchImpl }), + now, + }) + if (result.ok) sent += 1 + } + return sent +} + export async function deliverFeedbackToLead(input: TriagePacketInput & { instances: LeadTransport | undefined fetchImpl?: typeof fetch diff --git a/test/db.test.ts b/test/db.test.ts index 40d07f36..2f75d6ad 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -151,8 +151,8 @@ test('the role backfill runs once and future schema versions are refused', () => const reopened = new Store(path) assert.equal(reopened.getRepo(repo.id)?.repo_role, 'maintainer') assert.notEqual(reopened.getRepo(repo.id)?.role_checked_at, null) - // #814 moved the build's schema to 42, so the first refused version is 43. - reopened.db.exec('PRAGMA user_version = 43;') + // #814 moved the build's schema to 43, so the first refused version is 44. + reopened.db.exec('PRAGMA user_version = 44;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) diff --git a/test/feedbackLead.test.ts b/test/feedbackLead.test.ts index c0ea1889..2e854605 100644 --- a/test/feedbackLead.test.ts +++ b/test/feedbackLead.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { buildApp } from '../src/app.js' import { Store } from '../src/db.js' import { MockRunner } from '../src/jobs/mock.js' -import { buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, triageToken, verifyTriageToken } from '../src/feedback/lead.js' +import { attemptLeadDelivery, buildTriagePacket, deliverFeedbackToLead, publicIssueDraft, retryPendingLeadDeliveries, triageToken, verifyTriageToken } from '../src/feedback/lead.js' import { notifyReleasedFeedback } from '../src/feedback/notify.js' import { json, makeUser, MemoryMailer, readBody, startFakeServer, testConfig } from './helpers.js' @@ -86,6 +86,58 @@ test('a configured bridge target posts a send to the configured session', async } }) +test('a failed Lead delivery is recorded and retried, then marked delivered', async () => { + const f = fixture() + let calls = 0 + const server = await startFakeServer(async (request, response) => { + await readBody(request) + calls += 1 + json(response, calls === 1 ? 500 : 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + const first = await attemptLeadDelivery({ store: f.store, config, instances: undefined, triage: f.triage, feedback: f.feedback, now: 1_000 }) + assert.equal(first.ok, false) + const failed = f.store.feedbackTriageByRef(f.triage.ref) + assert.equal(failed?.lead_attempts, 1) + assert.match(failed?.lead_last_error ?? '', /500/) + assert.ok((failed?.lead_next_attempt_at ?? '') !== '') + assert.equal(failed?.lead_delivered_at, null) + + const second = await attemptLeadDelivery({ store: f.store, config, instances: undefined, triage: failed!, feedback: f.feedback, now: 2_000 }) + assert.equal(second.ok, true) + const delivered = f.store.feedbackTriageByRef(f.triage.ref) + assert.ok(delivered?.lead_delivered_at) + assert.equal(delivered?.lead_next_attempt_at, null) + } finally { + f.store.close() + await server.close() + } +}) + +test('the retry worker sends only pending Lead deliveries', async () => { + const f = fixture() + let calls = 0 + const server = await startFakeServer(async (request, response) => { + await readBody(request) + calls += 1 + json(response, 200, { ok: true }) + }) + try { + const base = testConfig() + const config = testConfig({ feedback: { ...base.feedback, leadRepoId: '', leadBridgeUrl: server.url, leadBridgeToken: 'bridge-token', leadSessionId: 'lead-session' } }) + assert.equal(await retryPendingLeadDeliveries({ store: f.store, config, instances: undefined, now: 10_000 }), 1) + assert.equal(calls, 1) + assert.ok(f.store.feedbackTriageByRef(f.triage.ref)?.lead_delivered_at) + assert.equal(await retryPendingLeadDeliveries({ store: f.store, config, instances: undefined, now: 20_000 }), 0, 'a delivered row is not resent') + assert.equal(calls, 1) + } finally { + f.store.close() + await server.close() + } +}) + test('the triage API reads and writes with the per-ref token and notifies the reporter', async () => { const config = testConfig() const store = new Store(':memory:') diff --git a/test/scheduleLeases.test.ts b/test/scheduleLeases.test.ts index 4dd9249e..2781916e 100644 --- a/test/scheduleLeases.test.ts +++ b/test/scheduleLeases.test.ts @@ -45,11 +45,11 @@ function claim(h: { store: Store; repoId: string; scheduleId: string }, token: s }) } -test('the occurrence row carries attempts and a lease, at user_version 42', () => { +test('the occurrence row carries attempts and a lease, at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 42, 'the triage tables move the refusal boundary from 41 to 42') + assert.equal(Number(version?.user_version), 43, 'the Lead delivery columns move the refusal boundary from 42 to 43') const columns = store.all<{ name: string }>('PRAGMA table_info(schedule_occurrences)').map((column) => column.name) for (const column of ['attempts', 'lease_token', 'lease_expires_at']) { diff --git a/test/scheduleOccurrences.test.ts b/test/scheduleOccurrences.test.ts index ed039cfa..12bb7fe3 100644 --- a/test/scheduleOccurrences.test.ts +++ b/test/scheduleOccurrences.test.ts @@ -56,11 +56,11 @@ function audits(store: Store, action: string): string[] { // -- the table ------------------------------------------------------------- -test('the occurrence table is part of the schema at user_version 42', () => { +test('the occurrence table is part of the schema at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 42, 'the triage tables move the refusal boundary from 41 to 42') + assert.equal(Number(version?.user_version), 43, 'the Lead delivery columns move the refusal boundary from 42 to 43') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedule_occurrences'") assert.equal(table?.name, 'schedule_occurrences') diff --git a/test/scheduleSchema.test.ts b/test/scheduleSchema.test.ts index e501ebbc..42f18f89 100644 --- a/test/scheduleSchema.test.ts +++ b/test/scheduleSchema.test.ts @@ -7,11 +7,11 @@ import { makeUser } from './helpers.js' // the version boundary, the columns the planner reads, and the one-row-per- // repository rule the tick relies on. -test('the schedule table is part of the schema at user_version 42', () => { +test('the schedule table is part of the schema at user_version 43', () => { const store = new Store(':memory:') try { const version = store.get<{ user_version: number }>('PRAGMA user_version') - assert.equal(Number(version?.user_version), 42, 'each schema change moves the refusal boundary: 40 evolution consent, 41 feedback and error reports, 42 triage') + assert.equal(Number(version?.user_version), 43, 'each schema change moves the refusal boundary: 41 feedback and error reports, 42 triage, 43 Lead delivery') const table = store.get<{ name: string }>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schedules'") assert.equal(table?.name, 'schedules')