Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ flowchart TD
```

1. HTTP 接收原始字节,`Pipeline` 验签后解析并归一化事件,检查允许的源仓与 delivery 去重。只有有效且匹配的事件进入业务监听;未知、重复、范围外或验签失败的投递仍保存各自请求和应用响应。存储失败返回 503,不确认已保存。
2. Worker 的看板监听器仅暂存刷新意图。R2 正文/详情写入成功后,SQLite 事务一起提交索引、去重和刷新待办;随后由持久 Alarm 触发 `collect.yml`,HTTP 请求不会等待 Python 采集结束。
3. `collect.yml` 从目标 main 读取上轮进度与历史,校验唯一源仓映射。GitHub 为采集 job 签发 OIDC 身份;对应 Worker 的 `/actions/token` 校验该身份后,用本环境 App 私钥分别申请源仓只读、看板仓 Contents 写安装令牌。采集器用这些令牌读取 GitHub API,再将新进度与变化数据一起提交到看板仓;结束时尝试撤销令牌。Bot 的 dispatch 参数不携带令牌,看板 Actions 不保存 App 私钥。
2. Worker 的看板监听器暂存刷新意图,或合并 `workflow_run.completed` 的重跑 ID。R2 正文/详情写入成功后,SQLite 事务一起提交索引、去重和待办;随后仍由原有持久 Alarm 触发 `collect.yml`,重跑通知不提前调度,HTTP 请求不会等待 Python 采集结束。
3. `collect.yml` 从目标 main 读取上轮进度与历史,校验唯一源仓映射。GitHub 为采集 job 签发 OIDC 身份;对应 Worker 的 `/actions/token` 校验该身份后,用本环境 App 私钥分别申请源仓只读、看板仓 Contents 写安装令牌。采集器用这些令牌读取 GitHub API,再将新进度与变化数据一起提交到看板仓;结束时尝试撤销令牌。它还以固定采集身份每轮领取最多 20 条重跑 ID,按 ID 补采而不为此扫描全部历史,提交成功后才按 revision 确认;失败项留在 Worker,进度与指标仍只由看板仓维护。Bot 的 dispatch 参数不携带令牌,看板 Actions 不保存 App 私钥。
4. `pages.yml` 只在 `site/` 变化或手动运行时上传整个 `site/`。仅进度变化不触发部署;页面无变化时不能把“没有新 Pages 运行”判成采集失败。

Webhook 已接受、Actions 已触发、采集提交成功、Pages 部署成功是四个独立状态。Bot 的 `last_dispatch` 只代表触发成功,最终结果分别查看采集与发布工作流。详情见[看板更新](features/board-publication.md)。
Expand Down
2 changes: 2 additions & 0 deletions docs/features/actions-oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Worker 保留 `SDBOT_GITHUB_APP_ID` 和 Secret `SDBOT_GITHUB_APP_PRIVATE_KEY`,

`POST /actions/gitcode-sync` 使用同一身份校验(不需要请求体,也不受每次运行一次的限制),只返回本看板源仓的 GitCode 同步记录,不签发令牌、不进入 Webhook 档案;同步未启用时返回 `enabled: false`。见 [GitHub PR 同步到 GitCode](gitcode-sync.md)。

`POST /actions/reruns` 与 `POST /actions/reruns/ack` 也不签发令牌、不进入投递档案,只允许既有看板配置中的固定采集身份:仓库与所有者 ID、`main`、`.github/workflows/collect.yml`、`schedule`/`workflow_dispatch`、固定 audience 和管理域名;其他访问规则不能借此查询待办,新增接口不扩大 OIDC 信任范围。读取请求为 `{}`,返回 `{"ok":true,"source":"owner/source","runs":[{"id":123,"attempt":2,"revision":"32位小写十六进制"}]}`,最多 20 条。确认请求为 `{"commit":"已提交的40位小写SHA","runs":[原样成功项]}`,正文上限 4096 字节;仅删除版本仍匹配的项。客户端必须在公开数据和进度提交成功后确认,并重新申请短期 OIDC 身份,避免长采集结束时原 JWT 已过期。接口不受令牌兑换次数限制,失败不清待办;响应均为 `no-store`。见 [CI 重跑通知](board-publication.md#ci-重跑通知)。

成功返回 `token`、`expires_at`、`repository`、`purpose`(请求没带时为 null)、`permissions`,响应 `Cache-Control: no-store`。认证失败为 401,身份超出信任范围为 403,重复兑换为 409,配置不完整为 503,上游签发失败为 502。发布新版本后的几分钟内,Worker 的后台对象可能还在运行旧代码,这时返回 503 且带 `Retry-After: 60` 和 `retryable: true`,稍后重跑即可。错误只返回短说明,不透传上游响应。该端点及 `/actions/` 保留路径不进入 Webhook 档案;它也不能查询管理信息。

## 采集器与失败恢复
Expand Down
10 changes: 10 additions & 0 deletions docs/features/board-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ App 安装到源仓及目标仓。Bot 触发时只申请目标仓 Metadata read

Workers 使用持久 Alarm 和事务 outbox 调用同一个 Actions 入口,详见 [Workers 指南](workers.md)。不要同时运行两个 Bot 调度同站点。切换 Node 到 Actions 不会自动部署 Cloudflare Worker。

## CI 重跑通知

Worker 的 `board.on_workflow_run` 监听已验签、范围内且未重复的 GitHub `workflow_run.completed`。仅当 `workflow_run.status=completed`、`run_attempt>1` 时登记重跑;第一次执行仍由现有增量列表发现,不为每次普通完成增加一条待办。不依赖重跑时不会触发的 `requested`。

待办只保存源仓、run ID、最新 attempt 和随机 revision,同一 run 合并,旧 attempt 不覆盖新值;与投递索引在归档成功后的同一 SQLite 事务内提交,重启不丢失。它不增加刷新代数、不重置合并窗口、不提前 Alarm,也不为每个完成事件 dispatch。通知随下一次原有 Issue/PR 刷新或空闲刷新一起处理,频率不变。Node 本地模式没有这条持久通知链路。

看板采集用现有 OIDC 身份调用 `POST /actions/reruns`,每轮最多读 20 条,轮转读取,失败项不会一直挡住后续 ID;只按 ID 查询最新 attempt、状态和必要报告,不为重跑翻全部 Actions 历史。看板将进度与公开数据原子提交后,才向 `POST /actions/reruns/ack` 确认成功项。删除必须同时匹配源仓、ID、attempt、revision,旧采集不能误删后来到达的新重跑。查询失败、预算耗尽、报告读取未完成、提交冲突或确认丢失均保留待办,下次沿用原频率再处理;不保存额外正文或产物。接口权限见 [Actions OIDC](actions-oidc.md)。

## 看板仓负责的工作

每次从 main 读取 `.sync/` 和 `site/data/history/`,在请求/时间预算内增量刷新、补历史、重试报告。进度与数据在同一提交内写入;使用准确 checkout 父提交,冲突则失败并从新 main 重试,禁止强推。看板仓自己的定时每天一次,只在 Bot 停止触发时兜底;无站点变化只更新进度,不触发 Pages。
Expand All @@ -49,3 +57,5 @@ Issue / PR 和 run / attempt 指标分片长期保留;不复制测试日志、
## 验证

`npm run check`、`npm test` 覆盖 Webhook 完整存档、范围/去重、队列恢复、触发失败重试、App 最小权限及管理状态。`tests-ts/board-app.test.ts` 明确验证 dispatch 不伪造提交或发布成功。实际部署验收应核对 Actions 完成、进度与数据同一提交、Pages 完成及站点内容。

`tests-ts/worker-run-changes.test.mjs` 在真实 workerd 中验证完成通知合并、事务回滚、重启、轮转批次、OIDC 范围和带版本确认,并断言重跑通知不改变 outbox 与 Alarm。看板仓的 `test/journey-rerun-collection.py` 用隔离 HTTP 源和真实采集 CLI 验证旧 run 变绿、失败保留与发布冲突不确认。
1 change: 1 addition & 0 deletions docs/features/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ router.bus.subscribe({
- 内容分析:Issue opened/edited/reopened,Issue 评论,PR opened/synchronize/reopened/edited/ready_for_review、评审和合并;当前仅记录调用,不调用 LLM 或发评论。
- 看板更新:只有 Issue 新建、重新打开、关闭,PR 新建、重新打开、关闭(含合并,合并即关闭 PR)立即触发采集;重新打开按新建处理。转为可评审、评审、`workflow_run`、发布、job/check、push、分支与标签、评论和编辑都不触发,由每 8 小时一次的空闲刷新(`SDBOT_BOARD_REFRESH`)补上;CI、测试与覆盖率因此最多延迟约 8 小时,除非期间有 Issue/PR 新建、重新打开或关闭带动一次完整采集。
- 未配置发布时,看板监听处于占位状态;启用发布后显示 active,并列出实际来源范围。详见[看板更新](board-publication.md)。
- Worker 另注册实际监听 `board.on_workflow_run`,只把范围内的 `workflow_run.completed` 重跑(attempt 大于 1)合并成待更新 run ID;不立即触发采集、不改变上述刷新频率。待原有刷新按 ID 补查并成功发布后确认,失败保留。Node 本地模式不注册此监听。
- GitCode 同步:PR opened/synchronize/reopened/closed/merged。监听器只把每个 PR 的最新期望状态写入持久队列,推送、MR 操作和 CodeCheck 读取由队列在 Webhook 之外完成。另一监听点只收同步目标仓的 GitCode MR 评论与标签变化,把对应 PR 的结论读取提前到现在;不轮询。第三个监听点收 GitHub 上对 `CodeCheck (GitCode)` 的重新运行(`check_run.rerequested`/`check_suite.rerequested`):已同步的 head 立即重新读取结论,未同步成功的重新同步。没有 `GITCODE_TOKEN`、`SDBOT_GITCODE_SYNC_TARGET=off`、缺少 GitHub App 凭据或缺少 GitHub Webhook secret 时,以 `enabled: false` 注册,监听点页显示「已停用」并列出全部原因,不改变 PR 投递的 hooks,也不让 Worker 停止服务。详见 [GitHub PR 同步到 GitCode](gitcode-sync.md)。

## 可观测性与验证
Expand Down
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ npm run test:e2e:cloud-admin

Actions 触发模式还需验证队列重启后恢复、失败不确认代数,以及 `last_dispatch` 不被误报成 `last_success` 或 Pages 提交。看板仓负责历史续跑与指标缓存的 Python、浏览器及真实 Actions 验证。

`node --import tsx --test tests-ts/worker-run-changes.test.mjs` 是重跑通知的 API 旅程:真实 workerd 接收签名 Webhook,使用现场签发的 OIDC 身份读写待办,验证重复、重启、事务回滚、轮转及旧确认不删除新 attempt;不请求真实 Actions,也断言不提前刷新。看板仓另有 `PYTHONDONTWRITEBYTECODE=1 python3 test/journey-rerun-collection.py`,通过本地 HTTP 替身运行实际发布 CLI,验证窗口外重跑变绿、源端失败和发布冲突不确认。

## 提交前

云端没有测试实例:合入 `main` 即由 Workers Builds 发布正式 Worker `sciencediscovery-bot`,所以上面的本地检查、workerd 测试和浏览器旅程就是合入前的全部验证,不再有“先部署测试 Worker”这一步。
Expand Down
3 changes: 3 additions & 0 deletions src/core/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ export function registerBuiltin(bus: EventBus, board: Board, sync: PullRequestSy
];
for (const [method, routes, exclude] of boards) bus.subscribe({ id: `board.${method}`, business: '看板更新', description: board.mode === 'active' ? '按源仓排队更新对应静态看板。' : '未启用静态发布,当前仅记录调用。',
routes, exclude, mode: board.mode, providers: board.mode === 'active' ? ['github'] : [], repositories: board.repositories, handler: event => board.handle(method, event) });
if (board.observeRun) bus.subscribe({ id: 'board.on_workflow_run', business: '看板更新', routes: ['workflow_run.completed'],
description: '仅合并记录重跑完成的 run ID,等待原有刷新节奏批量补采,不立即触发采集。',
providers: ['github'], repositories: board.repositories, mode: board.mode, enabled: board.mode === 'active', handler: event => board.observeRun!(event) });
// Without a token, with target `off`, or with missing GitHub credentials, the listener stays registered
// but disabled, so PR routes and hooks are unchanged and the Worker keeps serving everything else.
const active = sync.mode === 'active';
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface Board {
readonly mode: 'active' | 'noop';
readonly repositories: readonly string[];
handle(method: string, event: BotEvent): Promise<Doc>;
/** Workers may retain changed run IDs without requesting an immediate refresh. */
observeRun?(event: BotEvent): Promise<Doc>;
status(): Doc;
}
/** Stable public codes for why GitCode sync is not running; missing credentials can appear together. */
Expand Down
24 changes: 24 additions & 0 deletions src/worker/actions-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GitHubApp } from '../core/github-app.js';
import { object } from '../core/types.js';
import { PURPOSE, matchActionsRule, type ActionsClaims, type ActionsRule } from '../core/actions-rules.js';
import { OBJECT_NAME, workerConfig, type WorkerEnv } from './env.js';
import { RUN_CHANGE_BATCH, validRunChange } from './run-changes.js';
import { describePermissions } from './token-grants.js';

const issuer = 'https://token.actions.githubusercontent.com';
Expand Down Expand Up @@ -120,3 +121,26 @@ export async function readGitCodeSync(request: Request, env: WorkerEnv): Promise
try { return jsonResponse(await env.BOT.getByName(OBJECT_NAME).gitcodeSync()); }
catch { return fail('sync records unavailable', 503); }
}

/** Collector-only rerun hints. Acknowledgement follows the dashboard's atomic publication. */
export async function runChanges(request: Request, env: WorkerEnv, acknowledge = false): Promise<Response> {
const verified = await verifyCollector(request, env, false);
if (verified instanceof Response) return verified;
const captured = await captureBody(request, 4096);
if (captured.error) return fail(captured.error.message, captured.error.status);
let input: Record<string, unknown>;
try {
const raw = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(captured.body) || '{}');
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return fail('bad request', 400);
input = object(raw);
} catch { return fail('bad request', 400); }
const bot = env.BOT.getByName(OBJECT_NAME);
if (!acknowledge) {
if (Object.keys(input).length) return fail('bad request', 400);
return jsonResponse({ ok: true, source: verified.source, runs: await bot.runChanges(verified.source) });
}
if (Object.keys(input).sort().join(',') !== 'commit,runs' || typeof input.commit !== 'string' || !/^[0-9a-f]{40}$/.test(input.commit)
|| !Array.isArray(input.runs) || input.runs.length > RUN_CHANGE_BATCH || !input.runs.every(validRunChange)) return fail('bad request', 400);
await bot.ackRunChanges(verified.source, input.runs);
return jsonResponse({ ok: true });
}
10 changes: 9 additions & 1 deletion src/worker/board.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { boardBlocked, targets, type Config } from '../core/config.js';
import { WorkerRunChanges } from './run-changes.js';
import { category, type Board, type BotEvent, type Doc } from '../core/types.js';

interface DispatchState {
Expand All @@ -12,7 +13,9 @@ export class WorkerBoard implements Board {
readonly repositories: string[];
readonly blocked: '' | 'no_github_app';
private readonly pending = new Set<string>();
readonly changes: WorkerRunChanges;
constructor(readonly storage: DurableObjectStorage, readonly cfg: Config, readonly clock = () => Date.now()) {
this.changes = new WorkerRunChanges(storage);
// Without App credentials nothing can be dispatched: report it instead of scheduling doomed work.
this.blocked = boardBlocked(cfg, true);
this.repositories = this.blocked ? [] : Object.keys(targets(cfg)).map(source => source.toLowerCase());
Expand All @@ -36,9 +39,14 @@ export class WorkerBoard implements Board {
if (event.provider !== 'github' || !this.repositories.includes(event.repo.toLowerCase())) return { hook: 'board', method, status: this.mode === 'noop' ? 'noop' : 'ignored' };
this.pending.add(event.repo.toLowerCase()); return { hook: 'board', method, status: 'queued' };
}
clearPending(): void { this.pending.clear(); }
async observeRun(event: BotEvent): Promise<Doc> {
if (event.provider !== 'github' || !this.repositories.includes(event.repo.toLowerCase())) return { status: 'ignored' };
return this.changes.stage(event);
}
clearPending(): void { this.pending.clear(); this.changes.clearPending(); }
commitPending(): void {
for (const source of this.pending) this.request(this.get(source)!);
this.changes.commitPending();
this.pending.clear();
}
private request(state: DispatchState): void {
Expand Down
6 changes: 5 additions & 1 deletion src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import type { ActionsRule } from '../core/actions-rules.js';
import { dispatchCollection } from '../core/actions.js';
import { Mutex, object, type Doc } from '../core/types.js';
import { WorkerArchive, type PruneResult } from './archive.js';
import type { RunChange } from './run-changes.js';
import { WorkerBoard } from './board.js';
import { OBJECT_NAME, workerConfig, type WorkerEnv } from './env.js';
import { authorizeAdmin } from './access.js';
import { exchangeActionsToken, readGitCodeSync } from './actions-auth.js';
import { exchangeActionsToken, readGitCodeSync, runChanges } from './actions-auth.js';
import { WorkerGitCodeSync } from './gitcode-sync.js';
import { AnalyticsCache } from './usage.js';
import { WorkerForwards, type AdminReply } from './forward.js';
Expand Down Expand Up @@ -133,6 +134,8 @@ export class BotObject extends DurableObject<WorkerEnv> {
tokenGrants(): Doc { return { ok: true, retention_days: GRANT_RETENTION_DAYS, limit: GRANT_LIMIT, shown: GRANT_PAGE, grants: this.grants.list() }; }
// Read-only RPC for the OIDC-authenticated dashboard collector.
gitcodeSync(): Promise<Record<string, unknown>> { return this.sync.snapshot(); }
runChanges(source: string): RunChange[] { return this.board.changes.list(source); }
ackRunChanges(source: string, rows: RunChange[]): void { this.board.changes.ack(source, rows); }
async wake(): Promise<void> { await this.mutex.run(() => this.schedule()); await this.forwards.schedule(); }
// Cron-driven retention step; bounded per call and independent of the ingestion mutex.
pruneArchive(now?: number): Promise<PruneResult> { return this.archive.prune(now); }
Expand Down Expand Up @@ -199,6 +202,7 @@ export default {
const url = new URL(request.url);
if (url.pathname === '/actions/token') return await exchangeActionsToken(request, env);
if (url.pathname === '/actions/gitcode-sync') return await readGitCodeSync(request, env);
if (url.pathname === '/actions/reruns' || url.pathname === '/actions/reruns/ack') return await runChanges(request, env, url.pathname.endsWith('/ack'));
if (url.pathname === '/actions' || url.pathname.startsWith('/actions/')) return jsonResponse({ ok: false, error: 'not found' }, 404);
// Certificate callers authenticate with their own JWT; this path is never behind Access and never a webhook.
if (url.pathname === '/caller' || url.pathname.startsWith('/caller/')) return await handleCaller(request, env);
Expand Down
Loading