diff --git a/.github/workflows/sync-upstream-alert.yml b/.github/workflows/sync-upstream-alert.yml new file mode 100644 index 000000000000..1e58f78f4d7e --- /dev/null +++ b/.github/workflows/sync-upstream-alert.yml @@ -0,0 +1,64 @@ +# alpha-code auto-sync — ALERT phase (`#995`). +# +# 每日 `sync-upstream` 失败时**通知一个人**。此前它失败的唯一输出是一条 Actions 日志, +# 而没有任何东西读那条日志:实测(2026-09-19 勘破)最后一次成功是 `2026-07-22T08:06:35Z`, +# 此后 **59 次连续失败、零通知**;9 月初上游是人手工追平的(`ac#1248`),cron 仍在每天红。 +# +# 这个文件与 `sync-upstream-push.yml` 是**同一个形状的两半**:候选 job +# (`sync-upstream.yml`,无任何凭据、执行上游代码)跑完之后,由两个 `workflow_run` 消费者 +# 分别接走两种结局 —— `success` 归推送,`failure` 归本文件。此前只有前一半有消费者, +# 失败那一半的 `conclusion` **没有任何读者**,这就是 `#995` 的全部内容。 +# +# `#899`(SEC)的同一条纪律:本 job 持有 `issues: write`,所以它**不执行合并树里的任何代码** —— +# checkout 的是 `alpha`(我们自己的树)、**不跑 `bun install`**、只跑一个零依赖的 +# `scripts/sync-upstream-alert.ts`。不要往这个文件里加安装/构建/跑引擎的步骤:那会把一个 +# 有写权限的 token 放进与上游代码同一个进程,正是 `#899` 拆两半要消灭的形状。 +# +# 行为闸:`packages/ui-mac/src/main/sync-upstream-alert.test.ts` —— 它起一个真的 HTTP 服务 +# 冒充 GitHub API,跑**生产的那个脚本本体**,断言它真的发出了写入;并从本文件解析接线 +# (监视哪条 workflow、只在 failure 时跑、权限、没有 install 步)。 +name: sync-upstream-alert + +on: + workflow_run: + workflows: ["sync-upstream"] + types: [completed] + +# `issues: write` 是这个 job 存在的全部理由。**不要**在这里加 `contents: write` +# 或引用 `secrets.SYNC_TOKEN` —— 推送凭据只属于 sync-upstream-push.yml。 +# `actions: read` 用于读本次 run 的结论、失败步与连败长度。 +permissions: + contents: read + issues: write + actions: read + +# 刻意**不**与 sync-upstream 共用 concurrency group:共用会让告警排在下一轮候选后面, +# 而告警的价值与时效直接相关。 +concurrency: + group: sync-upstream-alert + cancel-in-progress: false + +jobs: + alert: + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + runs-on: ubuntu-latest + steps: + - name: Checkout alpha (our own tree, no push credential) + uses: actions/checkout@v4 + with: + ref: alpha + persist-credentials: false + + - name: Setup bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + # 零依赖:这一步**没有** `bun install`。脚本只用 `fetch`,见其抬头的信任边界一节。 + - name: File or refresh the sync-upstream failure issue + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + ALERT_RUN_ID: ${{ github.event.workflow_run.id }} + ALERT_WORKFLOW: sync-upstream.yml + run: bun scripts/sync-upstream-alert.ts diff --git a/docs/architecture/upstream-integration.md b/docs/architecture/upstream-integration.md index e8531cc0e711..b16bfc7539c5 100644 --- a/docs/architecture/upstream-integration.md +++ b/docs/architecture/upstream-integration.md @@ -77,6 +77,64 @@ order and assert the same fixture dies with `failed to resolve` and prints no `::error::` at all — the gate is proven to detect the known-bad before it is trusted about the unknown-good. +### A failing sync had no reader for two months (`#995`) + +Measured 2026-09-19 from the real run history, not inferred from the workflow: +the last successful `sync-upstream` run was `2026-07-22T08:06:35Z` +(run `29902706228`), and every run since — **59 consecutive daily runs, +2026-07-23 through 2026-09-19** — failed. Nobody was notified for any of them. +Upstream reached Alpha in that window exactly once, by hand (`ac#1248`, +`efced9fa9`, 2026-09-06). + +The streak is **not one cause**. Classifying each failing run by the step the +Actions API reports as failed (the `##[error]Unexpected merge conflicts` string +also appears in the *echoed script body*, so a plain `grep` for it matches every +run — that fingerprint is useless): + +| window | runs | what actually failed | +| --- | --- | --- | +| 2026-07-23 → 2026-08-07 | 16 | `bun install` — `@opencode-ai/client@file:../app/vendor/opencode-ai-client-1.17.13[-v2].tgz failed to resolve`. This is the pre-`#1272` order defect above. The 07-23 run had already merged and pushed (`e77266975..02407fcfa alpha -> alpha`) before dying in the smoke step. | +| 2026-08-08 → 2026-09-06 | 30 | merge aborted — unexpected conflict in `packages/desktop/src/main/index.ts` (plus `packages/ui/src/v2/components/dialog-v2.tsx` from 09-06). | +| 2026-09-07 → 2026-09-19 | 13 | merge aborted — unexpected conflict in `packages/opencode/test/provider/transform.test.ts`. | + +The current abort is **correct behaviour, and it will recur**. The conflicting +file is on the north-star annexation whitelist +([`scripts/north-star-guard.sh`](../../scripts/north-star-guard.sh) +`UPSTREAM_EXCLUDES`, 48 entries): Alpha deliberately edits it (`#1147`), so the +merge step's message "the only-add discipline was broken" is literally false +here — the discipline was waived on purpose. Every annexed upstream file is a +permanent conflict generator, and a conflict there genuinely requires a human +(auto-resolving would silently drop one side). `ac#1248` demonstrates the shape: +its catch-up merge landed 2026-09-06 and the very next run, 09-07, aborted again +because upstream had moved on — 7 of the 272 upstream commits since that +merge-base touch `transform.test.ts`. + +So the defect `#995` fixes is not the abort. It is that +`sync-upstream.yml`'s failure conclusion had **no reader at all**: +[`sync-upstream-push.yml`](../../.github/workflows/sync-upstream-push.yml) +consumes `conclusion == 'success'` and nothing consumed the other half. +[`sync-upstream-alert.yml`](../../.github/workflows/sync-upstream-alert.yml) is +that missing half, built to the same trust shape: a `workflow_run` consumer that +holds a write scope (`issues: write`) precisely because it executes **no code +from the merged tree** — it checks out `alpha`, runs no `bun install`, and +invokes only the dependency-free +[`scripts/sync-upstream-alert.ts`](../../scripts/sync-upstream-alert.ts). + +That script files or refreshes one tracking Issue labelled +`sync-upstream-failure`: it creates and assigns on the first failure of a +streak, re-comments only when the failing step changes, and otherwise just +refreshes the body with the streak length and the latest run. Fifty-nine daily +comments would be silenced as fast as fifty-nine silent failures were ignored. +Every API error, and a missing token, exit non-zero — a notification that did +not go out must turn its own run red rather than return 0. + +The gate is +[`packages/ui-mac/src/main/sync-upstream-alert.test.ts`](../../packages/ui-mac/src/main/sync-upstream-alert.test.ts): +it serves a real HTTP stub of the GitHub API, spawns the production script +against it, and asserts the writes it actually issued. Its wiring cases carry +mutation arms (flip the job's `if` to `success`, point it at another workflow, +add a `bun install` step) so the checker is proven to detect the known-bad. + ## Sovereignty ladder ADR-029 defines the only supported ways to change upstream behavior: diff --git a/packages/ui-mac/src/main/sync-upstream-alert.test.ts b/packages/ui-mac/src/main/sync-upstream-alert.test.ts new file mode 100644 index 000000000000..8b4e936ed0cd --- /dev/null +++ b/packages/ui-mac/src/main/sync-upstream-alert.test.ts @@ -0,0 +1,301 @@ +// `#995` —— `scripts/sync-upstream-alert.ts` 与 `.github/workflows/sync-upstream-alert.yml` 的**行为闸**。 +// +// 这道门守的是什么(大白话):每天 06:00 UTC 那条上游同步流水线失败时,**必须有一个人被叫到**。 +// 此前它失败的唯一输出是一条 Actions 日志,而没有任何东西读那条日志 —— 实测(2026-09-19 +// `ac#995` 勘破):最后一次成功 `2026-07-22T08:06:35Z`,此后 **59 次连续失败、零通知**, +// 9 月初上游是人手工追平的(`ac#1248`),而这条 cron 仍在每天红。 +// +// 为什么判据必须是**行为**的: +// · 通知逻辑写在 workflow 里的一段内联 `actions/github-script` 时,它一个判据都没有 —— +// 断言 YAML 里写着某段 JS 在本仓是点名过的假闸门形态(那段 JS 被换成 `return` 时照样绿)。 +// 所以逻辑住在 `scripts/sync-upstream-alert.ts`,而这里起一个**真的 HTTP 服务**冒充 +// GitHub API、用 `Bun.spawn` 跑**生产的那个文件本体**,断言它真的发出了那次写入。 +// · **先证明这个手段能测出已知的坏,再用它判未知的好。** 第 1 条是正对照(没有它,一个 +// 什么都不做的空壳能满足下面全部「不许发评论」类断言);接线那两条各带**三个变异臂** +// (`if` 改成 success / 监视别的 workflow / 加一步 `bun install`),断言同一个检查器当场点名。 +// · **fail-closed 要被证明**:API 500、缺 token 时脚本必须**非零退出**。「通知没发出去」 +// 安静返回 0,就是本票要消灭的那个形状换了个地方复活。 +// +// 删掉本文件会失去什么:那条告警链退回零行为判据 —— 脚本被改成只打印不写、指纹去重被改成 +// 永不发评论、fail-closed 被改成吞掉错误、workflow 的 `if` 被改成只在 success 时跑、 +// 有人往那个持有 `issues: write` 的 job 里加一句 `bun install`(把写权限 token 放进与上游 +// 代码同一个进程,`#899` 拆两半正是要消灭这个),都不会有任何东西变红 —— 那条链只在 +// GitHub 上、且只在 sync 失败时才走到。它因此登记在 scripts/gate-files.tsv 里,拿精确条数。 + +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { describe, expect, test } from "bun:test" + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..") +const SCRIPT = resolve(REPO_ROOT, "scripts/sync-upstream-alert.ts") +const ALERT_WORKFLOW = resolve(REPO_ROOT, ".github/workflows/sync-upstream-alert.yml") +const CANDIDATE_WORKFLOW = resolve(REPO_ROOT, ".github/workflows/sync-upstream.yml") + +const REPO = "jinjunnn/alpha-code" +const RUN_ID = "35435906706" +const MERGE_STEP = "Merge `dev` into `alpha` (local only, no push)" +const SMOKE_STEP = "Engine runtime smoke (S39 — boot/probe/kill/reboot)" + +// ── 冒充 GitHub 的桩 ──────────────────────────────────────────────────────────── + +type Recorded = { method: string; path: string; body: any } +type StubOpts = { + /** 本次 run 的结论 */ + conclusion?: string + /** 本次 run 的失败步 */ + failingSteps?: string[] + /** workflow run 历史(新→旧),只给 conclusion;本次 run 排第一 */ + history?: string[] + /** `GET /issues?labels=…` 返回什么 */ + openIssues?: any[] + /** 让某个 `METHOD /path 前缀` 返回 500 —— 用来证明 fail-closed */ + failOn?: { method: string; path: string } +} + +function startStub(opts: StubOpts) { + const recorded: Recorded[] = [] + const conclusion = opts.conclusion ?? "failure" + const history = opts.history ?? ["failure", "failure", "failure"] + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + const path = url.pathname + const body = req.method === "GET" ? undefined : await req.json().catch(() => null) + recorded.push({ method: req.method, path, body }) + if (opts.failOn && req.method === opts.failOn.method && path.startsWith(opts.failOn.path)) { + return new Response(JSON.stringify({ message: "stub failure" }), { status: 500 }) + } + const json = (v: unknown) => new Response(JSON.stringify(v), { headers: { "content-type": "application/json" } }) + + if (path === `/repos/${REPO}/actions/runs/${RUN_ID}`) + return json({ id: Number(RUN_ID), conclusion, html_url: `https://github.com/${REPO}/actions/runs/${RUN_ID}` }) + + if (path === `/repos/${REPO}/actions/runs/${RUN_ID}/jobs`) + return json({ + jobs: [ + { + name: "candidate", + steps: [ + { name: "Checkout", conclusion: "success" }, + ...(opts.failingSteps ?? [MERGE_STEP]).map((name) => ({ name, conclusion: "failure" })), + ], + }, + ], + }) + + if (path === `/repos/${REPO}/actions/workflows/sync-upstream.yml/runs`) + return json({ + workflow_runs: history.map((c, i) => ({ + id: i === 0 ? Number(RUN_ID) : 1000 + i, + conclusion: c, + // 新→旧:第 i 条比第 i-1 条早一天 + created_at: new Date(Date.UTC(2026, 8, 19 - i, 6, 0, 0)).toISOString(), + })), + }) + + if (path === `/repos/${REPO}/issues` && req.method === "GET") return json(opts.openIssues ?? []) + if (path === `/repos/${REPO}/issues` && req.method === "POST") return json({ number: 4242 }) + if (/^\/repos\/.+\/issues\/\d+\/comments$/.test(path)) return json({ id: 999001 }) + if (/^\/repos\/.+\/issues\/\d+$/.test(path)) return json({ number: 7 }) + + return new Response(JSON.stringify({ message: `stub 没有这条路由:${req.method} ${path}` }), { status: 404 }) + }, + }) + return { server, recorded, base: `http://127.0.0.1:${server.port}` } +} + +type RunResult = { exitCode: number; stdout: string; stderr: string; recorded: Recorded[] } + +async function runAlert(opts: StubOpts & { env?: Record } = {}): Promise { + const stub = startStub(opts) + try { + const env: Record = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: process.env.HOME ?? "/tmp", + GITHUB_API_URL: stub.base, + GITHUB_TOKEN: "stub-token", + GITHUB_REPOSITORY: REPO, + ALERT_RUN_ID: RUN_ID, + ALERT_WORKFLOW: "sync-upstream.yml", + } + for (const [k, v] of Object.entries(opts.env ?? {})) { + if (v === undefined) delete env[k] + else env[k] = v + } + const proc = Bun.spawn(["bun", SCRIPT], { cwd: REPO_ROOT, env, stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + const exitCode = await proc.exited + return { exitCode, stdout, stderr, recorded: stub.recorded } + } finally { + stub.server.stop(true) + } +} + +const writes = (r: Recorded[]) => r.filter((x) => x.method !== "GET") +const comments = (r: Recorded[]) => r.filter((x) => x.method === "POST" && x.path.endsWith("/comments")) +const creates = (r: Recorded[]) => r.filter((x) => x.method === "POST" && x.path === `/repos/${REPO}/issues`) +const patches = (r: Recorded[]) => r.filter((x) => x.method === "PATCH") + +function trackingIssue(fingerprint: string | null, extra: Record = {}) { + return { + number: 7, + body: fingerprint === null ? "没有指纹锚的旧正文" : `\n正文`, + ...extra, + } +} + +// ── 生产脚本的行为 ────────────────────────────────────────────────────────────── + +describe("#995 sync-upstream 失败告警的行为", () => { + test("没有追踪 issue ⇒ 建一张,正文带连败条数/失败步/run 链接,并指派给 owner", async () => { + const r = await runAlert({ openIssues: [], history: ["failure", "failure", "failure", "success"] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(creates(r.recorded).length).toBe(1) + const sent = creates(r.recorded)[0]!.body + expect(sent.assignees).toEqual(["jinjunnn"]) + expect(sent.labels).toContain("sync-upstream-failure") + expect(sent.title).toContain("3") + expect(sent.body).toContain(MERGE_STEP) + expect(sent.body).toContain(`/actions/runs/${RUN_ID}`) + expect(sent.body).toContain(`fingerprint=${MERGE_STEP}`) + expect(comments(r.recorded).length).toBe(0) + }) + + test("已有追踪 issue 且失败形态未变 ⇒ **不发评论**,只 PATCH 正文(维持真相,不制造噪声)", async () => { + const r = await runAlert({ openIssues: [trackingIssue(MERGE_STEP)] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(creates(r.recorded).length).toBe(0) + expect(comments(r.recorded).length).toBe(0) + expect(patches(r.recorded).length).toBe(1) + expect(patches(r.recorded)[0]!.path).toBe(`/repos/${REPO}/issues/7`) + }) + + test("已有追踪 issue 但失败形态变了 ⇒ 发一条评论,且两个形态都写进去(人要再被叫一次)", async () => { + const r = await runAlert({ openIssues: [trackingIssue(SMOKE_STEP)], failingSteps: [MERGE_STEP] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(comments(r.recorded).length).toBe(1) + const sent = comments(r.recorded)[0]!.body.body as string + expect(sent).toContain(SMOKE_STEP) + expect(sent).toContain(MERGE_STEP) + expect(patches(r.recorded).length).toBe(1) + }) + + test("连败长度在第一个非 failure 处**停住** —— 杀掉「数窗口里有几条 failure」的实现", async () => { + const r = await runAlert({ + openIssues: [], + history: ["failure", "failure", "success", "failure", "failure", "failure", "failure"], + }) + expect(r.exitCode, r.stderr).toBe(0) + const sent = creates(r.recorded)[0]!.body + // 窗口里共 6 条 failure,但结束于本次的连败只有 2 条。 + expect(sent.title).toContain("2") + expect(sent.body).toContain("| 连续失败 | **2** 次 |") + }) + + test("run 的结论不是 failure ⇒ 退出 0 且**一个写入都不发**(这道通知自己不许变成噪声源)", async () => { + const r = await runAlert({ conclusion: "success", openIssues: [] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(writes(r.recorded).length).toBe(0) + }) + + test("带 label 的是 PR 而不是 issue ⇒ 忽略它并新建(/issues 端点会把 PR 也返回回来)", async () => { + const r = await runAlert({ openIssues: [{ number: 555, body: "", pull_request: { url: "x" } }] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(creates(r.recorded).length).toBe(1) + expect(comments(r.recorded).length).toBe(0) + }) + + test("带 label 但正文没有 marker(人手写的票)⇒ **一个字都不许改它**,另开一张", async () => { + // 身份锚必须是正文里的 marker,不是 label:label 是给人筛选用的,人随时会把它贴到一张 + // 手写的票上。只认 label 的实现会把 owner 写的正文整段 PATCH 掉 —— 静默、不可逆、 + // 而且长得像「通知正常工作」。这一条就是钉住那个方向。 + const r = await runAlert({ openIssues: [trackingIssue(null)] }) + expect(r.exitCode, r.stderr).toBe(0) + expect(patches(r.recorded).length).toBe(0) + expect(comments(r.recorded).length).toBe(0) + expect(creates(r.recorded).length).toBe(1) + }) + + test("fail-closed:写 issue 的那次调用 500 ⇒ **非零退出**,并打 `::error::`", async () => { + const r = await runAlert({ openIssues: [], failOn: { method: "POST", path: `/repos/${REPO}/issues` } }) + expect(r.exitCode).not.toBe(0) + expect(r.stderr).toContain("::error::") + expect(r.stderr).toContain("500") + }) + + test("fail-closed:缺 GITHUB_TOKEN ⇒ 非零退出,且**一次 HTTP 都不发**", async () => { + const r = await runAlert({ openIssues: [], env: { GITHUB_TOKEN: undefined } }) + expect(r.exitCode).not.toBe(0) + expect(r.stderr).toContain("GITHUB_TOKEN") + expect(r.recorded.length).toBe(0) + }) +}) + +// ── 接线(它跑不跑、以什么身份跑)──────────────────────────────────────────────── + +type Doc = any +const parse = (p: string): Doc => Bun.YAML.parse(readFileSync(p, "utf8")) + +/** + * 告警 workflow 的接线问题清单。写成**函数**而不是一串 `expect`,是为了能拿变异臂证明 + * 它测得出已知的坏 —— 否则这几条与「grep 到某段文本」是同一种假闸门。 + */ +function wiringProblems(doc: Doc): string[] { + const problems: string[] = [] + const trigger = doc?.on?.workflow_run + if (!trigger) problems.push("没有 on.workflow_run 触发器") + else { + if (!(trigger.workflows ?? []).includes("sync-upstream")) problems.push("没有监视 sync-upstream") + if (!(trigger.types ?? []).includes("completed")) problems.push("没有监听 completed") + } + const job = doc?.jobs?.alert + if (!job) problems.push("没有 jobs.alert") + else { + if (!String(job.if ?? "").includes("conclusion == 'failure'")) problems.push("job 的 if 没有钉住 failure") + const steps: any[] = job.steps ?? [] + if (!steps.some((s) => typeof s?.run === "string" && s.run.includes("scripts/sync-upstream-alert.ts"))) + problems.push("没有一步跑生产脚本") + if (steps.some((s) => typeof s?.run === "string" && /(^|\n)\s*bun install\b/.test(s.run))) + problems.push("持有 issues:write 的 job 里出现了 bun install") + } + if (doc?.permissions?.issues !== "write") problems.push("没有 issues: write") + if (doc?.permissions?.contents !== "read") problems.push("contents 不是 read") + return problems +} + +describe("#995 告警链的接线", () => { + test("生产 workflow 接线无问题,且三个变异臂各被点名(先证明这个检查器测得出已知的坏)", () => { + const doc = parse(ALERT_WORKFLOW) + expect(wiringProblems(doc)).toEqual([]) + + const mutate = (fn: (d: Doc) => void) => { + const copy = JSON.parse(JSON.stringify(doc)) + fn(copy) + expect(JSON.stringify(copy), "变异一个字节都没改到 —— 本次测量作废,不是通过").not.toBe(JSON.stringify(doc)) + return wiringProblems(copy) + } + expect(mutate((d) => (d.jobs.alert.if = "${{ github.event.workflow_run.conclusion == 'success' }}"))).toContain( + "job 的 if 没有钉住 failure", + ) + expect(mutate((d) => (d.on.workflow_run.workflows = ["alpha-ci"]))).toContain("没有监视 sync-upstream") + expect(mutate((d) => d.jobs.alert.steps.push({ name: "x", run: "bun install" }))).toContain( + "持有 issues:write 的 job 里出现了 bun install", + ) + }) + + test("候选 workflow 的 `#899` 不变量没被这张票动过:contents:read、无 issues 权限、无 SYNC_TOKEN", () => { + const doc = parse(CANDIDATE_WORKFLOW) + expect(doc?.permissions?.contents).toBe("read") + expect(doc?.permissions?.issues).toBeUndefined() + // 判**解析后的文档**,不判源码文本:这个文件的抬头逐字写着「no `secrets.SYNC_TOKEN` + // reference anywhere in this file」,裸 `grep` 会被自己那句注释命中 —— 观测手段自己瞎了 + // 的标准形态(第一版写的就是那样,当场假红)。`Bun.YAML.parse` 丢掉注释,留下的才是接线。 + const wired = (d: Doc) => JSON.stringify(d).includes("secrets.SYNC_TOKEN") + expect(wired(doc)).toBe(false) + // 先证明这个手段测得出已知的坏:往同一份文档里塞一次真引用,它必须命中。 + const mutated = JSON.parse(JSON.stringify(doc)) + mutated.jobs.candidate.steps[0].env = { SYNC_TOKEN: "${{ secrets.SYNC_TOKEN }}" } + expect(wired(mutated)).toBe(true) + }) +}) diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index bd3bfe24f9b2..061c4618d879 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -290,3 +290,4 @@ 21 packages/ui-mac src/main/chat-archive-uploader.test.ts - REQ-160 `#1324` 对话存档上报的**生产路径**判据(注入 fetch,不发真实请求)。守住四条线契约(alpha-web docs/contracts/chat-archive-upload.md):①**429 不是终态** —— 503 → 重试 → 429 → 游标停在原地 → 重发 → 200 才推进,且断言的是**落盘的那份游标**(实测把 429 那一格删回旧口径:游标当场被推到 msg_0002,即数据库故障期卡住的那批轮次被永久丢弃);②`turn` 是**字符串字段**不是 Blob part、附件按位置叫 attachment_、content-type 交给 fetch 写 boundary;③429 以外的 4xx 才终态推进并把服务端那个一码一因的错误码原样记下;④引擎读失败/网络错只记一行不抛,前一轮没结论时后一轮不得抢先推游标;⑤R1 BLOCKER:**401 同样不是终态** —— 令牌「在场但已过期」时整条积压会被逐条 401,当终态就是把这批全推过去;判据是「过期令牌一趟 → 游标一步不动 → 换好令牌后两轮原样重发」;⑥R1 MAJOR:积压超过一页时按 `X-Next-Cursor` 往回翻到游标为止,且**旧的先发**(只读最新一页会跳过窗口外的旧轮次,而游标一旦跳上去它们永不再发);翻页有上界且到顶时如实记一行;⑦R1 MAJOR:引擎自建的子会话(`parentID` 在场)在**会话准入层**整会话拒掉,连消息都不读,读不出来也 fail-closed —— 子代理会话里那条「user 消息」是父模型写出来的字;配一条顶层会话照常上报,证明这道准入不是恒拒;⑧R1+R2 MAJOR:换账号(`getAuthIdentityTag()` 变,标记**落盘**)即重铸 enabled_at 并丢掉旧游标 —— 含 R2 那条跨重启窗口(B 登录后本进程一次 idle 都没跑就重启,盘上仍是 A 的游标);另有一条钉住「没有会话游标时按 enabled_at 收口,不把整条会话史翻到上界」,A 登出后用 BYOK 聊的轮次不得用 B 的 bearer 发出去(带前提自证:A 在场时那一轮确实该报)。另有 /global/event 上只有 session.idle 触发上报(心跳与 server.connected 不触发)。删掉本文件:429 / 401 都可以被改回「4xx 一律终态」、翻页可以退回只读一页、子会话准入可以整段删掉、游标可以不绑账号,而没有任何东西变红。精确条数=实测 21,不留余量 13 packages/ui-mac src/main/chat-archive-cursor.test.ts packages/ui-mac/src/main/chat-archive-uploader.test.ts REQ-160 `#1324` 游标文件与响应归类的纯逻辑半场:2xx 推进 / 429 与 401 以外的 4xx 终态 / 401、429、5xx 与未列状态一律可重试(未知状态倒向重试 —— 推进是不可逆的丢弃)、Retry-After 解析(空串必须先挡掉:`Number("")` 是 0 不是 NaN)、enabled_at 首次即落盘(否则每次启动把「当下」往后挪、期间的轮次两头落空)、游标只向前走、文件坏了当「现在启用」重铸而不是当空游标去回填全部历史、落盘失败不抛;401 那一格单独判(与线契约表**有意**相左,编排器 2026-09-18 裁决);换账号即重铸 enabled_at + 丢旧游标(标记落盘,开文件即比,所以跨重启也认得出),而登出(标记 undefined)**不得**被当成换账号 —— 重铸会把本账号未上报的积压静默丢掉。端到端那两条(游标在 429 / 401 之后没动)委派给上报器那份 —— 只断言纯函数会漏掉分流层。精确条数=实测 13,不留余量 13 packages/ui-mac src/main/chat-archive-turn.test.ts - REQ-160 `#1324` 切轮与 `turn` part 形状:一轮恰两条 user→assistant 且两条都带 provider_id/model_id;消息上不得出现 content_hash(服务端自己算),六个服务端主键/租户列名在**任意深度**上都不出现(遍历自带反向自证);没有 user 前驱的 assistant 跳过、compaction 产出经两条独立标记(summary:true / 前驱只挂 compaction part)各自判得出来、引擎注入的 synthetic 文本不算用户说的话且剔干净后整轮跳过;不回填启用前完成的轮次;附件只认 data URL、白名单外 mime 丢掉但整轮照发、超 8 个截断、哈希与字节同序。期望值全是手打字面量,不从被测模块 import 常量。精确条数=实测 13,不留余量 +11 packages/ui-mac src/main/sync-upstream-alert.test.ts - `#995`:`scripts/sync-upstream-alert.ts` + `.github/workflows/sync-upstream-alert.yml` 那条**失败告警链**的行为闸 —— 它守的是「每日 sync-upstream 失败时必须有一个人被叫到」。为什么这条不变量需要一道门:实测(2026-09-19 勘破)`sync-upstream` 最后一次成功是 `2026-07-22T08:06:35Z`,此后 **59 次连续失败、零通知**;失败的唯一输出是一条 Actions 日志,而**没有任何东西读那条日志**(`sync-upstream-push.yml` 只消费 `conclusion == 'success'`,失败那一半的结论没有读者)。9 月初上游是人手工追平的(`ac#1248`),而 cron 仍在每天红。为什么判据必须是**行为**的:逻辑若内联在 workflow 的 `actions/github-script` 里就一个判据都没有(断言 YAML 里写着某段 JS 是本仓点名过的假闸门形态 —— 那段 JS 被换成 `return` 时照样绿),所以逻辑住在 `scripts/sync-upstream-alert.ts`,本文件起一个**真的 HTTP 服务**冒充 GitHub API、用 `Bun.spawn` 跑**生产的那个文件本体**,断言它真的发出了那次写入。十条各钉一个方向:①没有追踪 issue ⇒ 建一张且正文带连败条数/失败步/run 链接、指派给 owner(正对照 —— 缺它,一个什么都不做的空壳能满足下面全部「不许发评论」类断言);②已有追踪 issue 且失败形态未变 ⇒ **不发评论**只 PATCH 正文(连续 59 天各发一条评论与没有通知是同一个结局:人会把它静音);③失败形态变了 ⇒ 发一条评论且新旧形态都写进去;④连败长度在第一个非 failure 处**停住**(杀掉「数窗口里有几条 failure」的实现:夹具窗口里共 6 条 failure 而结束于本次的连败只有 2 条);⑤run 结论不是 failure ⇒ 退出 0 且**一个写入都不发**(这道通知自己不许变成噪声源);⑥带 label 的是 PR 而不是 issue ⇒ 忽略并新建(`/issues` 端点会把 PR 一起返回);⑦带 label 但正文**没有 marker**(人手写的票)⇒ 一个字都不许改它、另开一张 —— 身份锚是正文里的 marker 而不是 label,只认 label 的实现会把 owner 写的正文整段 PATCH 掉(静默、不可逆、而且长得像「通知正常工作」;这个缺陷正是本票实现期自己写出来又被这一条抓回来的,变异臂实测:身份改回只认 label ⇒ 10 pass/1 fail);⑦fail-closed:写 issue 的调用 500 ⇒ **非零退出**并打 `::error::`(「通知没发出去」安静返回 0 就是本票要消灭的形状换了个地方复活);⑧fail-closed:缺 `GITHUB_TOKEN` ⇒ 非零退出且**一次 HTTP 都不发**;⑨接线:从生产 workflow 解析出触发器/`if`/权限/步骤,并带**三个变异臂**(`if` 改成 success / 监视别的 workflow / 往持有 `issues: write` 的 job 里加一步 `bun install`),断言同一个检查器当场点名 —— 先证明这个手段测得出已知的坏;⑩`#899` 不变量没被本票动过:候选 workflow 仍 `contents: read`、无 `issues` 权限、解析后的文档里无 `secrets.SYNC_TOKEN`(判**解析后的文档**而不是源码文本 —— 那个文件抬头逐字写着「no `secrets.SYNC_TOKEN` reference anywhere in this file」,裸 grep 会被自己那句注释命中,第一版写的就是那样、当场假红),并带一个把真引用塞回去的变异臂。本文件另经三条**对生产脚本本体**的变异臂证明过它真的绑着那个脚本(实测 2026-09-19:去掉发评论分支 ⇒ 9 pass/1 fail;改成永远发评论 ⇒ 9 pass/1 fail;catch 里 `process.exit(1)` 改 `exit(0)` ⇒ 8 pass/2 fail;还原后 10 pass)。删掉本文件:那条告警链退回零行为判据 —— 脚本被改成只打印不写、去重被改成永不发评论、fail-closed 被改成吞错、workflow 的 `if` 被改成只在 success 时跑、有人往那个持有 `issues: write` 的 job 里加 `bun install`(把写权限 token 放进与上游代码同一个进程,`#899` 拆两半正是要消灭这个),都不会有任何东西变红。精确条数 = 实测 11,不留余量 diff --git a/scripts/sync-upstream-alert.ts b/scripts/sync-upstream-alert.ts new file mode 100644 index 000000000000..5b91299b97e4 --- /dev/null +++ b/scripts/sync-upstream-alert.ts @@ -0,0 +1,216 @@ +/** + * `#995` —— 每日 `sync-upstream` 失败时**通知一个人**。 + * + * 这个脚本守的是什么(大白话):`.github/workflows/sync-upstream.yml` 每天 06:00 UTC 起一次 + * run。它失败时唯一的输出是一条 Actions 日志 —— 而**没有任何东西会去读那条日志**。实测 + * (2026-09-19,`ac#995` 勘破):最后一次成功是 `2026-07-22T08:06:35Z`,此后 + * **59 次连续失败,零通知**;9 月初上游是人手工追平的(`ac#1248`),而这条 cron 仍在每天红。 + * 一个天天失败、没人看的自动任务等于没有。 + * + * ── 为什么它是一个**文件**,而不是 workflow 里的一段内联 `actions/github-script` ───────── + * 与 `#889`(north-star 守卫)、`#717`(detect 分类步)是同一个理由:内联时它一个判据都没有。 + * 断言 YAML 里写着某段 JS 在本仓是点名过的**假闸门**形态 —— 那段 JS 被换成 `return` 时它照样绿。 + * 抽成文件的唯一目的,是让 `packages/ui-mac/src/main/sync-upstream-alert.test.ts` 能起一个 + * **真的 HTTP 服务**冒充 GitHub API、跑**生产的这一份**,断言它真的发出了那次写入。 + * + * ── 信任边界(`#899` 的同一条纪律)───────────────────────────────────────────────── + * 调用它的 workflow 持有 `issues: write`,所以那个 job **不执行合并树里的任何代码**: + * 它 checkout 的是 `alpha`(我们自己的树)、不跑 `bun install`、只跑这个零依赖的文件。 + * 本文件因此**只用 `fetch`**,不 import 任何第三方包 —— 依赖一旦出现,那条信任论证就断了。 + * + * ── fail-closed ──────────────────────────────────────────────────────────────────── + * 任何一次 API 调用非 2xx、缺 token、缺 run id ⇒ **非零退出**。「通知没发出去」必须让 + * alert run 自己变红,不许安静地返回 0 —— 那正是本票要消灭的形状(失败了而没人知道)。 + * + * ── 噪声:一次失败一条通知,一段连败一条 issue ─────────────────────────────────────── + * 连续 59 天各开一张 issue 或各发一条评论,与没有通知是同一个结局(人会把它静音)。规则: + * · 没有带 ALERT_LABEL 的 open issue ⇒ **建一张**(GitHub 给 assignee 发通知); + * · 已有 ⇒ 只在**失败形态变了**(失败步名变化)时再发一条评论; + * · 无论哪种,都把正文刷新成本次的事实(连败条数 / 最近一次 run / 上次成功是什么时候)。 + * PATCH 正文**不产生通知**,这是有意的:它维持真相,不制造噪声。 + * + * 用法(env,全部必填除非标了默认值): + * GITHUB_TOKEN 有 issues:write 的 token + * GITHUB_REPOSITORY owner/repo + * ALERT_RUN_ID 触发本次告警的那个 run 的 id + * ALERT_WORKFLOW 被监视的 workflow 文件名(默认 sync-upstream.yml) + * ALERT_LABEL 追踪标签(默认 sync-upstream-failure) + * GITHUB_API_URL API 根(默认 https://api.github.com;测试用它指向本地桩) + * GITHUB_SERVER_URL 站点根(默认 https://github.com,仅用于拼链接) + */ + +const API = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "") +const SERVER = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "") +const LABEL = process.env.ALERT_LABEL || "sync-upstream-failure" +const WORKFLOW_FILE = process.env.ALERT_WORKFLOW || "sync-upstream.yml" +/** 正文里的隐藏指纹锚 —— 下一次运行据此判断「失败形态变了没有」。 */ +const MARKER_RE = // + +class AlertError extends Error {} + +function required(name: string): string { + const v = process.env[name] + if (!v) throw new AlertError(`缺少必填环境变量 ${name} —— 通知发不出去,本次告警作废(不是「没有失败」)。`) + return v +} + +async function api(token: string, method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${API}${path}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + "user-agent": "alpha-code-sync-upstream-alert", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + const text = await res.text() + if (!res.ok) throw new AlertError(`${method} ${path} → HTTP ${res.status}:${text.slice(0, 400)}`) + if (!text) return null + try { + return JSON.parse(text) + } catch { + throw new AlertError(`${method} ${path} 返回的不是 JSON(前 200 字节:${text.slice(0, 200)})—— 本次告警作废。`) + } +} + +/** 本次 run 里失败的**步骤名**。它就是下面的指纹 —— 失败形态换了,人需要再被叫一次。 */ +async function failingSteps(token: string, repo: string, runId: string): Promise { + const jobs = await api(token, "GET", `/repos/${repo}/actions/runs/${runId}/jobs?per_page=100`) + const out: string[] = [] + for (const job of jobs?.jobs ?? []) { + for (const step of job?.steps ?? []) { + if (step?.conclusion === "failure" && typeof step?.name === "string") out.push(step.name) + } + } + return out +} + +type Streak = { failures: number; since: string | null; lastSuccess: string | null } + +/** + * 从这条 workflow 最近的 run 里数出**结束于本次的连败**。这个数字是本票存在的理由: + * 一句「今天失败了」和一句「这是连续第 59 天」触发的是完全不同的处置。 + */ +async function streak(token: string, repo: string, runId: string): Promise { + const runs = await api( + token, + "GET", + `/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?per_page=100&status=completed`, + ) + const list: any[] = runs?.workflow_runs ?? [] + // API 已按新→旧排序;不依赖它,自己按 created_at 排一次。 + list.sort((a, b) => Date.parse(b?.created_at ?? 0) - Date.parse(a?.created_at ?? 0)) + const startAt = list.findIndex((r) => String(r?.id) === String(runId)) + const from = startAt < 0 ? 0 : startAt + let failures = 0 + let since: string | null = null + for (let i = from; i < list.length; i++) { + if (list[i]?.conclusion !== "failure") break + failures++ + since = list[i]?.created_at ?? since + } + const lastSuccess = list.find((r) => r?.conclusion === "success")?.created_at ?? null + return { failures, since, lastSuccess } +} + +function issueBody(args: { + repo: string + runUrl: string + runId: string + steps: string[] + streak: Streak + fingerprint: string +}): string { + const { repo, runUrl, runId, steps, streak: s, fingerprint } = args + const stepLine = steps.length ? steps.map((n) => `\`${n}\``).join("、") : "(本次 run 没有报出失败步 —— 见日志)" + return [ + ``, + "", + "**谁会遇到**:负责把上游开源引擎的更新并进我们产品的人,间接是 owner。" + + "**遇到什么**:每天自动跑的「同步上游」任务正在失败,上游的更新没有进来。" + + "**为什么是问题**:它一天不修,我们就一天拿不到上游的修复;而且它失败的时候**不会自己停**," + + "只会每天再失败一次。**不做会怎样**:上游更新只能靠人想起来手工并一次。", + "", + "---", + "", + `| | |`, + `|---|---|`, + `| 连续失败 | **${s.failures}** 次 |`, + `| 起自 | ${s.since ?? "(窗口内数不到起点,连败长度超过一页 run 历史)"} |`, + `| 最后一次成功 | ${s.lastSuccess ?? "(最近 100 次 run 里没有成功)"} |`, + `| 失败步 | ${stepLine} |`, + `| 最近一次 run | [${runId}](${runUrl}) |`, + "", + `失败原文:\`gh run view ${runId} -R ${repo} --log-failed\`。`, + "", + "**这张 issue 由 `.github/workflows/sync-upstream-alert.yml` 维护**:正文每次失败后刷新," + + "只有**失败形态变化**时才再发一条评论。关掉它而 sync 仍在失败 ⇒ 下一次失败会重新开一张。", + ].join("\n") +} + +async function main(): Promise { + const token = required("GITHUB_TOKEN") + const repo = required("GITHUB_REPOSITORY") + const runId = required("ALERT_RUN_ID") + + const run = await api(token, "GET", `/repos/${repo}/actions/runs/${runId}`) + const conclusion = run?.conclusion + if (conclusion !== "failure") { + // 防御性:workflow 的 `if:` 已经筛过一次。这里再筛一次,是为了让「拿一个绿的 run 跑本脚本」 + // 结构性地写不出 issue —— 否则这道通知本身会变成噪声源。 + console.log(`run ${runId} 的结论是 ${conclusion ?? "(空)"},不是 failure —— 不告警。`) + return 0 + } + const runUrl = run?.html_url ?? `${SERVER}/${repo}/actions/runs/${runId}` + + const steps = await failingSteps(token, repo, runId) + const s = await streak(token, repo, runId) + const fingerprint = steps.length ? steps.join(" | ") : "(no-failing-step)" + const body = issueBody({ repo, runUrl, runId, steps, streak: s, fingerprint }) + + const open: any[] = await api( + token, + "GET", + `/repos/${repo}/issues?state=open&labels=${encodeURIComponent(LABEL)}&per_page=100`, + ) + // 身份锚是**正文里的 marker**,不是 label。label 是给人筛选用的,人随时会把它贴到一张 + // 手写的票上 —— 只认 label 就意味着下面那次 PATCH 会把**别人写的正文整段覆盖掉**。 + // 只碰自己盖过章的那张;没盖章的一律当作「不是我的」,另开一张。 + const tracking = (open ?? []).find((i) => i && !i.pull_request && MARKER_RE.test(String(i.body ?? ""))) + + if (!tracking) { + const created = await api(token, "POST", `/repos/${repo}/issues`, { + title: `sync-upstream 连续失败 ${s.failures} 次 —— 上游同步已停摆`, + body, + labels: [LABEL, "type:bug"], + assignees: [repo.split("/")[0]], + }) + console.log(`created issue #${created?.number}(连败 ${s.failures},失败步:${fingerprint})`) + return 0 + } + + const prev = MARKER_RE.exec(String(tracking.body ?? ""))?.[1] ?? null + if (prev !== fingerprint) { + const comment = await api(token, "POST", `/repos/${repo}/issues/${tracking.number}/comments`, { + body: + `sync-upstream 的**失败形态变了**(上一次记录:${prev ?? "(无)"} → 现在:${fingerprint})。\n\n` + + `连败 ${s.failures} 次,最近一次 run:${runUrl}`, + }) + console.log(`commented on #${tracking.number}(id ${comment?.id};指纹 ${prev ?? "(无)"} → ${fingerprint})`) + } else { + console.log(`#${tracking.number} 已在追踪同一形态(${fingerprint}),不再发评论 —— 只刷新正文。`) + } + await api(token, "PATCH", `/repos/${repo}/issues/${tracking.number}`, { body }) + return 0 +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err) + console.error(`::error::sync-upstream 告警没发出去:${msg}`) + process.exit(1) + })