From 188cbf59dabd8dc3965be98568b3f9e3ff480009 Mon Sep 17 00:00:00 2001 From: HugoXK Date: Wed, 4 Nov 2026 16:37:55 +0800 Subject: [PATCH 1/3] feat: attribute API usage and job tokens to repositories Store repo_id on api_tokens and quota_ledger so usage can be attributed per repository. This is internal plumbing and does not change user-visible behavior. Docs-not-needed: internal attribution columns; budgets are documented separately. --- src/app.ts | 1 + src/db.ts | 22 +++++++++++++++------- src/flybot/instances.ts | 1 + src/gateway/gateway.ts | 2 +- src/jobs/service.ts | 1 + test/db.test.ts | 17 ++++++++++++++++- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 64b5947..0d9674f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1765,6 +1765,7 @@ export async function buildApp(options: BuildOptions): Promise { name, tokenHash: sha256Hex(raw), prefix: raw.slice(0, 8), + repoId: null, expiresAt: null, }) store.audit(viewer.user.id, 'token.created', name) diff --git a/src/db.ts b/src/db.ts index 65d6021..26002f1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -193,6 +193,7 @@ export interface ApiTokenRow { name: string token_hash: string prefix: string + repo_id: string | null expires_at: string | null revoked_at: string | null last_used_at: string | null @@ -426,6 +427,7 @@ CREATE TABLE IF NOT EXISTS api_tokens ( name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, prefix TEXT NOT NULL, + repo_id TEXT, expires_at TEXT, revoked_at TEXT, last_used_at TEXT, @@ -438,6 +440,7 @@ CREATE TABLE IF NOT EXISTS quota_ledger ( at TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, job_id TEXT, + repo_id TEXT, cost_usd REAL NOT NULL, meta TEXT ); @@ -535,9 +538,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 > 18) { + if (previousVersion > 19) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (18)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (19)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -549,7 +552,7 @@ export class Store { if (previousVersion < 16) { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 18)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 19)};`) } private migrateColumns(): void { @@ -636,6 +639,10 @@ export class Store { if (!jobColumns.has('plan_allow_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_allow_paths TEXT') if (!jobColumns.has('plan_deny_paths')) this.run('ALTER TABLE jobs ADD COLUMN plan_deny_paths TEXT') if (!jobColumns.has('base_sha')) this.run('ALTER TABLE jobs ADD COLUMN base_sha TEXT') + const tokenColumns = new Set(this.all<{ name: string }>('PRAGMA table_info(api_tokens)').map((row) => row.name)) + if (!tokenColumns.has('repo_id')) this.run('ALTER TABLE api_tokens ADD COLUMN repo_id TEXT') + const ledgerColumns = new Set(this.all<{ name: string }>('PRAGMA table_info(quota_ledger)').map((row) => row.name)) + if (!ledgerColumns.has('repo_id')) this.run('ALTER TABLE quota_ledger ADD COLUMN repo_id TEXT') if (!jobColumns.has('base_branch')) this.run('ALTER TABLE jobs ADD COLUMN base_branch TEXT') this.run("UPDATE jobs SET base_branch = (SELECT upstream_default_branch FROM repos WHERE repos.id = jobs.repo_id) WHERE base_branch IS NULL") } @@ -1335,15 +1342,16 @@ export class Store { return this.all("SELECT * FROM jobs WHERE status = 'queued' AND created_at < ?", beforeIso) } - createApiToken(input: { id: string; userId: string; kind: string; name: string; tokenHash: string; prefix: string; expiresAt: string | null }): ApiTokenRow { + createApiToken(input: { id: string; userId: string; kind: string; name: string; tokenHash: string; prefix: string; repoId?: string | null; expiresAt: string | null }): ApiTokenRow { this.run( - 'INSERT INTO api_tokens (id, user_id, kind, name, token_hash, prefix, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO api_tokens (id, user_id, kind, name, token_hash, prefix, repo_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', input.id, input.userId, input.kind, input.name, input.tokenHash, input.prefix, + input.repoId ?? null, input.expiresAt, nowIso(), ) @@ -1406,9 +1414,9 @@ export class Store { this.run("UPDATE api_tokens SET revoked_at = ? WHERE kind = 'job' AND (expires_at IS NOT NULL AND expires_at <= ?) AND revoked_at IS NULL", nowIso(), nowIso()) } - recordUsage(userId: string, jobId: string | null, costUsd: number, meta: string | null = null): void { + recordUsage(userId: string, jobId: string | null, costUsd: number, meta: string | null = null, repoId: string | null = null): void { this.transaction(() => { - this.run('INSERT INTO quota_ledger (at, user_id, job_id, cost_usd, meta) VALUES (?, ?, ?, ?, ?)', nowIso(), userId, jobId, costUsd, meta) + this.run('INSERT INTO quota_ledger (at, user_id, job_id, repo_id, cost_usd, meta) VALUES (?, ?, ?, ?, ?, ?)', nowIso(), userId, jobId, repoId, costUsd, meta) this.run('UPDATE users SET quota_used_usd = quota_used_usd + ? WHERE id = ?', costUsd, userId) }) } diff --git a/src/flybot/instances.ts b/src/flybot/instances.ts index c95bcf6..8284665 100644 --- a/src/flybot/instances.ts +++ b/src/flybot/instances.ts @@ -241,6 +241,7 @@ server.listen(3081, '0.0.0.0') name: `flybot:${repo.id}`, tokenHash: sha256Hex(gatewayToken), prefix: gatewayToken.slice(0, 8), + repoId: repo.id, expiresAt: null, }) diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index bee52a1..f850ce3 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -225,7 +225,7 @@ export function registerGateway(app: FastifyInstance, deps: GatewayDeps): void { function finishCost(costUsd: number, usageValue: unknown): void { if (!funded || costUsd <= 0) return - store.recordUsage(auth!.user.id, jobId, costUsd, JSON.stringify({ model: targetModel, usage: usageValue, ms: Date.now() - started })) + store.recordUsage(auth!.user.id, jobId, costUsd, JSON.stringify({ model: targetModel, usage: usageValue, ms: Date.now() - started }), auth!.token.repo_id ?? null) if (jobId !== null) store.addJobCost(jobId, costUsd) log.info('gateway usage', { user: auth!.user.login, costUsd, jobId, model: targetModel }) const updated = store.userById(auth!.user.id) diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 03b272d..84c2e8c 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -300,6 +300,7 @@ export class Jobs { name: `job:${jobId}`, tokenHash: sha256Hex(platformToken), prefix: platformToken.slice(0, 8), + repoId: repo.id, expiresAt: addMinutes(this.config.runner.timeoutMinutes + 60), }) const priorJobs = this.store.listJobs(user.id, 1).length diff --git a/test/db.test.ts b/test/db.test.ts index 323d456..12fd77d 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -150,7 +150,7 @@ 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) - reopened.db.exec('PRAGMA user_version = 19;') + reopened.db.exec('PRAGMA user_version = 20;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) @@ -201,3 +201,18 @@ test('work items persist, update and are found by issue or PR', () => { store.close() } }) + +test('usage and job tokens carry the repository for cost attribution', () => { + const store = new Store(':memory:') + try { + const user = makeUser(store, { login: 'attribution-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_attribution', userId: user.id, owner: 'o', name: 'a', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.recordUsage(user.id, null, 0.2, null, repo.id) + const usage = store.get<{ repo_id: string | null }>('SELECT repo_id FROM quota_ledger ORDER BY id DESC LIMIT 1') + assert.equal(usage?.repo_id, repo.id) + const token = store.createApiToken({ id: 'tok_attr', userId: user.id, kind: 'job', name: 'job:attr', tokenHash: 'hash_attr', prefix: 'flg', repoId: repo.id, expiresAt: null }) + assert.equal(token.repo_id, repo.id) + } finally { + store.close() + } +}) From 7fe692c32d562afc6645401162787b53a19d0aaf Mon Sep 17 00:00:00 2001 From: HugoXK Date: Wed, 4 Nov 2026 16:46:39 +0800 Subject: [PATCH 2/3] feat: add repository and per-job budgets Add an optional rolling 30-day repository budget that blocks new jobs, and a per-job alert threshold that audits and notifies after a finished job. Cost is attributed through quota_ledger.repo_id. --- changelog.d/repo-budgets.added.md | 1 + docs/quota.md | 17 +++++++++++++++++ src/app.ts | 3 +++ src/db.ts | 30 ++++++++++++++++++++++++------ src/jobs/service.ts | 15 +++++++++++++++ src/views/pages.ts | 8 +++++++- test/db.test.ts | 2 +- test/jobs.test.ts | 25 +++++++++++++++++++++++++ 8 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 changelog.d/repo-budgets.added.md diff --git a/changelog.d/repo-budgets.added.md b/changelog.d/repo-budgets.added.md new file mode 100644 index 0000000..445d984 --- /dev/null +++ b/changelog.d/repo-budgets.added.md @@ -0,0 +1 @@ +A repository maintainer can now set an optional rolling 30-day repository budget and a per-job alert threshold. Once the repository budget is reached, new jobs are refused with `REPO_BUDGET_EXHAUSTED`; a job that costs more than its per-job budget produces an audit entry and a quota notification. diff --git a/docs/quota.md b/docs/quota.md index 9163ccc..91cceae 100644 --- a/docs/quota.md +++ b/docs/quota.md @@ -60,6 +60,23 @@ stream ends. A request that is already in flight is allowed to finish; the } ``` +## Repository and per-job budgets + +A maintainer can set two optional caps per repository in the repository +settings: + +- **Monthly repository budget (CNY)** — a rolling 30-day cap on the repository's + attributed platform spend. A new job is refused with + `REPO_BUDGET_EXHAUSTED` when the cap is reached. +- **Per-job budget (CNY)** — an alerting threshold, not a hard stop. When a + finished job's attributed cost exceeds it, Fledge writes a + `job.budget_exceeded` audit entry and an in-app quota notification. + +Cost is attributed through `quota_ledger.repo_id`; rows written before that +column existed fall back to the job's `repo_id` when they can be joined. Both +caps default to `0` (disabled) and are recorded in the repository audit entry +when changed. + ## Own model `POST /settings/model` stores: diff --git a/src/app.ts b/src/app.ts index 0d9674f..3a60782 100644 --- a/src/app.ts +++ b/src/app.ts @@ -883,6 +883,7 @@ export async function buildApp(options: BuildOptions): Promise { instance, jobs: store.listJobsPageForRepo(repo.id, 10, (jobsPage - 1) * 10), workItems: store.listWorkItems(repo.id), + repoSpendCny: store.repoUsageSince(repo.id, new Date(Date.now() - 30 * 86_400_000).toISOString()) * config.quota.usdCny, questions, chiefMessages, activeJob: store.activeJobForRepo(repo.id) !== undefined, @@ -1643,6 +1644,8 @@ export async function buildApp(options: BuildOptions): Promise { deny_paths: (body.denyPaths ?? '').trim().slice(0, 2000), merge_method: body.mergeMethod === 'merge' || body.mergeMethod === 'rebase' ? body.mergeMethod : 'squash', require_protection: body.requireProtection === '1' ? 1 : 0, + monthly_budget_cny: Math.max(0, Math.min(10000, Number(body.monthlyBudget) || 0)), + job_budget_cny: Math.max(0, Math.min(10000, Number(body.jobBudget) || 0)), auto_iterate: body.auto === '1' ? 1 : 0, enabled: body.enabled === '1' ? 1 : 0, interval_minutes: interval, diff --git a/src/db.ts b/src/db.ts index 26002f1..6d9e1fd 100644 --- a/src/db.ts +++ b/src/db.ts @@ -81,6 +81,8 @@ export interface RepoRow { deny_paths: string merge_method: string require_protection: number + monthly_budget_cny: number + job_budget_cny: number protection_state: string | null protection_checked_at: string | null auto_iterate: number @@ -328,6 +330,8 @@ CREATE TABLE IF NOT EXISTS repos ( deny_paths TEXT NOT NULL DEFAULT '', merge_method TEXT NOT NULL DEFAULT 'squash', require_protection INTEGER NOT NULL DEFAULT 1, + monthly_budget_cny REAL NOT NULL DEFAULT 0, + job_budget_cny REAL NOT NULL DEFAULT 0, protection_state TEXT, protection_checked_at TEXT, auto_iterate INTEGER NOT NULL DEFAULT 0, @@ -538,9 +542,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 > 19) { + if (previousVersion > 20) { this.db.close() - throw new Error(`database schema version ${previousVersion} is newer than this build supports (19)`) + throw new Error(`database schema version ${previousVersion} is newer than this build supports (20)`) } this.db.exec(SCHEMA) this.migrateColumns() @@ -552,7 +556,7 @@ export class Store { if (previousVersion < 16) { this.run('UPDATE repos SET role_checked_at = ? WHERE repo_role IS NOT NULL AND role_checked_at IS NULL', nowIso()) } - this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 19)};`) + this.db.exec(`PRAGMA user_version = ${Math.max(previousVersion, 20)};`) } private migrateColumns(): void { @@ -592,6 +596,8 @@ export class Store { ['deny_paths', "TEXT NOT NULL DEFAULT ''"], ['merge_method', "TEXT NOT NULL DEFAULT 'squash'"], ['require_protection', 'INTEGER NOT NULL DEFAULT 1'], + ['monthly_budget_cny', 'REAL NOT NULL DEFAULT 0'], + ['job_budget_cny', 'REAL NOT NULL DEFAULT 0'], ['protection_state', 'TEXT'], ['protection_checked_at', 'TEXT'], ['upstream_owner', "TEXT NOT NULL DEFAULT ''"], @@ -1029,6 +1035,8 @@ export class Store { denyPaths?: string mergeMethod?: string requireProtection?: boolean + monthlyBudgetCny?: number + jobBudgetCny?: number protectionState?: string | null protectionCheckedAt?: string | null upstreamOwner?: string @@ -1051,9 +1059,9 @@ export class Store { this.run( `INSERT INTO repos (id, user_id, owner, name, default_branch, token_enc, installation_id, goal, enabled, auto_iterate, interval_minutes, created_at, upstream_owner, upstream_name, fork_owner, fork_name, upstream_default_branch, fork_default_branch, target_branch, job_mode, - repo_role, auto_merge, role_checked_at, plan_required, allow_paths, deny_paths, merge_method, require_protection, protection_state, protection_checked_at, + repo_role, auto_merge, role_checked_at, plan_required, allow_paths, deny_paths, merge_method, require_protection, monthly_budget_cny, job_budget_cny, protection_state, protection_checked_at, is_fork, fork_created, permission, can_review, can_create_issue, reviewer_enabled, review_interval_minutes, upstream_html_url, fork_html_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.id, input.userId, input.owner, @@ -1081,6 +1089,8 @@ export class Store { input.denyPaths ?? '', input.mergeMethod ?? 'squash', input.requireProtection === false ? 0 : 1, + input.monthlyBudgetCny ?? 0, + input.jobBudgetCny ?? 0, input.protectionState ?? null, input.protectionCheckedAt ?? null, input.isFork === true ? 1 : 0, @@ -1123,7 +1133,7 @@ export class Store { return this.get('SELECT * FROM repos WHERE user_id = ? AND lower(owner) = lower(?) AND lower(name) = lower(?)', userId, owner, name) } - updateRepo(id: string, patch: Partial>): void { + updateRepo(id: 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(', ') @@ -1421,6 +1431,14 @@ export class Store { }) } + repoUsageSince(repoId: string, sinceIso: string): number { + const row = this.get<{ total: number | null }>( + 'SELECT SUM(q.cost_usd) AS total FROM quota_ledger q LEFT JOIN jobs j ON j.id = q.job_id WHERE COALESCE(q.repo_id, j.repo_id) = ? AND q.at >= ?', + repoId, sinceIso, + ) + return Number(row?.total ?? 0) + } + usageTotal(userId: string): number { const row = this.get<{ total: number | null }>('SELECT SUM(cost_usd) AS total FROM quota_ledger WHERE user_id = ?', userId) return Number(row?.total ?? 0) diff --git a/src/jobs/service.ts b/src/jobs/service.ts index 84c2e8c..71d9d1e 100644 --- a/src/jobs/service.ts +++ b/src/jobs/service.ts @@ -238,6 +238,13 @@ export class Jobs { if (active !== undefined) return { ok: false, error: '这个仓库已有任务在运行', code: 'JOB_ACTIVE' } const user = this.store.userById(repo.user_id) if (user === undefined || user.status !== 'approved') return { ok: false, error: '用户不可用', code: 'USER_UNAVAILABLE' } + if (repo.monthly_budget_cny > 0) { + const since = new Date(Date.now() - 30 * 86_400_000).toISOString() + const spentCny = this.store.repoUsageSince(repo.id, since) * this.config.quota.usdCny + if (spentCny >= repo.monthly_budget_cny) { + return { ok: false, error: '仓库月度预算已用完。', code: 'REPO_BUDGET_EXHAUSTED' } + } + } const repoBaseBranch = repo.target_branch !== '' ? repo.target_branch : (repo.upstream_default_branch || repo.default_branch) const baseBranch = (options.baseBranch ?? repoBaseBranch).trim() @@ -448,6 +455,14 @@ export class Jobs { if (result.status === 'failed') this.store.notify(job.user_id, 'job', `${job.kind === 'review' ? '审核' : '任务'} ${jobId} 失败:${result.error ?? job.error ?? '未知原因'}`) if (result.status === 'succeeded') void this.postAutomationComment(job, { prUrl: prUrl ?? undefined, issueUrl: issueUrl ?? undefined }, attribution) if (result.status === 'succeeded' || result.status === 'failed') void this.sendJobEmail(job, result) + const repo = this.store.getRepo(job.repo_id) + if (repo !== undefined && repo.job_budget_cny > 0) { + const jobCostCny = this.store.usageForJob(jobId) * this.config.quota.usdCny + if (jobCostCny > repo.job_budget_cny) { + this.store.audit(job.user_id, 'job.budget_exceeded', `${jobId}: ${jobCostCny.toFixed(4)} > ${repo.job_budget_cny}`) + this.store.notify(job.user_id, 'quota', `任务 ${jobId} 成本 ${jobCostCny.toFixed(2)} 元,超过单任务预算 ${repo.job_budget_cny.toFixed(2)} 元。`) + } + } void this.captureTeamStats(jobId) } diff --git a/src/views/pages.ts b/src/views/pages.ts index ea1592e..878ebb0 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -459,9 +459,10 @@ export function dashboardPage(input: { return layout({ title: t('仓库', 'Repositories'), viewer, body, config, locale, path: '/dashboard', active: 'dashboard', notice, error }) } -export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; workItems?: WorkItemRow[]; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { +export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer; repo: RepoRow; instance?: RepoInstanceRow; jobs: JobRow[]; workItems?: WorkItemRow[]; repoSpendCny?: number; questions?: InboxQuestion[]; chiefMessages?: ChiefMessage[]; activeJob?: boolean; jobsPage?: number; jobsTotal?: number; notice?: string; error?: string }): string { const { config, locale, viewer, repo, instance, jobs, notice, error } = input const workItems = input.workItems ?? [] + const repoSpendCny = input.repoSpendCny ?? 0 const questions = input.questions ?? [] const chiefMessages = input.chiefMessages ?? [] const jobsPage = input.jobsPage ?? 1 @@ -679,6 +680,11 @@ export function repoPage(input: { config: Config; locale: Locale; viewer: Viewer

${escapeHtml(t('Branch protection', 'Branch protection'))}: ${escapeHtml(repo.protection_state ?? 'unknown')}${repo.protection_checked_at === null ? '' : ` · ${escapeHtml(humanTimeShort(repo.protection_checked_at))}`}

+
+ + +
+

${escapeHtml(t('本月已用', 'Spent this month'))} ${escapeHtml(quotaMoney(repoSpendCny))}${repo.monthly_budget_cny > 0 ? ` / ${escapeHtml(quotaMoney(repo.monthly_budget_cny))}` : ''}

${roleMissing ? `

${escapeHtml(t('先选择你的角色,审核和自动合并才会生效。', 'Choose your role before review or auto-merge can take effect.'))}

` : !githubCanReview ? `

${escapeHtml(t('你在这个上游没有 admin/maintain 权限,审核角色不可用。', 'You do not have admin/maintain permission on this upstream, so the reviewer role is unavailable.'))}

` : !canMaintain ? `

${escapeHtml(t('developer 不能启用 PR 审核或自动合并;切换为 maintainer 后才可用。', 'A developer cannot enable PR review or auto-merge; switch to maintainer first.'))}

` : ''} ${advanced ? `
${escapeHtml(t('团队与调度规则', 'Team and scheduling rules'))}

${escapeHtml(t('保存目标后,团队 playbook 会按新目标更新:总控 → 负责人 → 产品/测试/安全/审核/开发。审核角色只有勾选“启用 PR 审核”后才会参与。', 'Saving the goal updates the team playbook: Chief -> Lead -> Product/Test/Security/Reviewer/Dev. The Reviewer takes part only when "Enable PR reviewer" is checked.'))}

diff --git a/test/db.test.ts b/test/db.test.ts index 12fd77d..a9b572b 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -150,7 +150,7 @@ 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) - reopened.db.exec('PRAGMA user_version = 20;') + reopened.db.exec('PRAGMA user_version = 21;') reopened.close() assert.throws(() => new Store(path), /newer than this build/) diff --git a/test/jobs.test.ts b/test/jobs.test.ts index d65e46c..6a55313 100644 --- a/test/jobs.test.ts +++ b/test/jobs.test.ts @@ -817,3 +817,28 @@ test('auto-merge is skipped when branch protection is unavailable', async () => await github.close() } }) + +test('repository monthly and per-job budgets are enforced and attributed', async () => { + const config = testConfig() + const store = new Store(':memory:') + const jobs = new Jobs(store, config, { name: 'noop', async run() { return { status: 'running' } } }, new MemoryMailer()) + try { + const user = makeUser(store, { login: 'budget-user', status: 'approved' }) + const repo = store.createRepo({ id: 'repo_budget', userId: user.id, owner: 'o', name: 'b', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', monthlyBudgetCny: 1, jobBudgetCny: 0.01, goal: '', autoIterate: false, intervalMinutes: 1440 }) + store.recordUsage(user.id, null, 0.2, null, repo.id) + assert.equal(store.repoUsageSince(repo.id, '2000-01-01T00:00:00.000Z'), 0.2) + const blocked = await jobs.enqueue(repo, 'manual') + assert.equal(blocked.ok, false) + assert.equal(blocked.code, 'REPO_BUDGET_EXHAUSTED') + + const cheap = store.createRepo({ id: 'repo_budget_job', userId: user.id, owner: 'o', name: 'bj', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', jobBudgetCny: 0.01, goal: '', autoIterate: false, intervalMinutes: 1440 }) + const secret = 'budget-job-secret' + store.createJob({ id: 'job_budget', repoId: cheap.id, userId: user.id, trigger: 'manual', goal: 'g', callbackHash: sha256Hex(secret), platformTokenHash: '', kind: 'iterate', baseBranch: 'main' }) + store.updateJob('job_budget', { status: 'running', started_at: new Date().toISOString() }) + store.recordUsage(user.id, 'job_budget', 0.02, null, cheap.id) + await jobs.handleCallback('job_budget', secret, { status: 'succeeded', summary: 'done' }) + assert.equal(store.listNotifications(user.id).some((entry) => entry.message.includes('超过单任务预算')), true) + } finally { + store.close() + } +}) From f8b45debbf0ff0a7eec4a6f416a95f9707f5a28b Mon Sep 17 00:00:00 2001 From: HugoXK Date: Wed, 4 Nov 2026 16:48:57 +0800 Subject: [PATCH 3/3] feat: add an admin support view for users and jobs Admins can inspect a visible user's repositories, recent jobs and audit entries, and open a job read-only without the mutating controls. --- changelog.d/admin-support-view.added.md | 1 + docs/architecture.md | 4 ++- src/app.ts | 47 +++++++++++++++++++++++++ src/views/pages.ts | 42 ++++++++++++++++++---- test/admin.test.ts | 31 ++++++++++++++++ 5 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 changelog.d/admin-support-view.added.md diff --git a/changelog.d/admin-support-view.added.md b/changelog.d/admin-support-view.added.md new file mode 100644 index 0000000..fc7ed91 --- /dev/null +++ b/changelog.d/admin-support-view.added.md @@ -0,0 +1 @@ +Admins now have a read-only support view for the users they are allowed to see, including that user's repositories, recent jobs and audit entries. The same view opens a job without the control buttons, so support can inspect a run without changing it or triggering new work. diff --git a/docs/architecture.md b/docs/architecture.md index a6787cd..8c4d601 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,9 @@ normal product UI plus a scoped management page for themselves and normal users; the super admin sees everyone, can set admins and is the only role that can set unlimited quota. The owner can toggle a normal-user preview cookie that - renders the simple UI while advanced routes stay blocked. + renders the simple UI while advanced routes stay blocked. An admin can open a + read-only support view for a visible user and that user's jobs; it never + mutates data or triggers work. - **No worker process**: a 60-second tick schedules due iterate jobs and, when the user selected the maintainer role and the upstream grants admin/maintain permission, review jobs for all open PRs. The interval is the minimum gap between runs, not a cap on PRs or issues; a diff --git a/src/app.ts b/src/app.ts index 3a60782..c96c7ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ import { isWorkItemState, MAINTAINER_ONLY_STATES, WORK_ITEM_TRANSITIONS, type Wo import { Approvals } from './users/approval.js' import { adminPage, + adminSupportPage, approvalPage, dashboardPage, landingPage, @@ -1814,6 +1815,52 @@ export async function buildApp(options: BuildOptions): Promise { })) }) + const adminCanViewUser = (viewer: { user: UserRow; preview?: boolean }, user: UserRow): boolean => { + if (viewer.preview === true) return false + if (viewer.user.role === 'superadmin') return true + return user.id === viewer.user.id || user.role === 'user' + } + + app.get('/admin/users/:id', async (request, reply) => { + const viewer = requireAdmin(request) + if (viewer.preview === true) return reply.redirect('/console') + const user = store.userById(String((request.params as Record).id)) + if (user === undefined || !adminCanViewUser(viewer, user)) throw httpError(404, '用户不存在。', 'USER_NOT_FOUND') + store.audit(viewer.user.id, 'admin.support_view', `user:${user.id}`) + return reply.type('text/html').send(adminSupportPage({ + config, + locale: request.locale ?? 'zh', + viewer: viewOf(viewer), + user, + repos: store.listRepos(user.id), + jobs: store.listJobsPageForUser(user.id, 20, 0), + audit: store.listAuditPageForUsers([user.id], 20, 0), + notice: queryText(request, 'notice'), + error: queryText(request, 'error'), + })) + }) + + app.get('/admin/jobs/:jobId', async (request, reply) => { + const viewer = requireAdmin(request) + if (viewer.preview === true) return reply.redirect('/console') + const job = store.getJob(String((request.params as Record).jobId)) + if (job === undefined) throw httpError(404, '任务不存在。', 'JOB_NOT_FOUND') + const repo = store.getRepo(job.repo_id) + const user = store.userById(job.user_id) + if (repo === undefined || user === undefined || !adminCanViewUser(viewer, user)) throw httpError(404, '任务不存在。', 'JOB_NOT_FOUND') + store.audit(viewer.user.id, 'admin.support_view', `job:${job.id}`) + return reply.type('text/html').send(jobPage({ + config, + locale: request.locale ?? 'zh', + viewer: viewOf(viewer), + repo, + job, + supportMode: true, + notice: queryText(request, 'notice'), + error: queryText(request, 'error'), + })) + }) + app.get('/admin', async (request, reply) => { const viewer = requireAdmin(request) if (viewer.preview === true) return reply.redirect('/console') diff --git a/src/views/pages.ts b/src/views/pages.ts index 878ebb0..965d0e5 100644 --- a/src/views/pages.ts +++ b/src/views/pages.ts @@ -1103,7 +1103,7 @@ export function adminPage(input: { const userRows = users .map( (user) => ` - @${escapeHtml(user.login)}
${escapeHtml(user.email ?? '')} + @${escapeHtml(user.login)}
${escapeHtml(user.email ?? '')}
${escapeHtml(t('支持视图', 'Support view'))} ${badge(t, user.status)} ${escapeHtml(roleLabel(t, user.role))} ${user.quota_unlimited === 1 ? escapeHtml(t('不限额', 'Unlimited')) : money(user.quota_cny)} @@ -1363,10 +1363,12 @@ export function jobPage(input: { repo: RepoRow job: JobRow queuePosition?: number + supportMode?: boolean notice?: string error?: string }): string { const { config, locale, viewer, repo, job, queuePosition, notice, error } = input + const supportMode = input.supportMode === true const t = translator(locale) const advanced = advancedViewer(viewer) const evidence = parseEvidenceJson(job.evidence_json) @@ -1415,15 +1417,15 @@ export function jobPage(input: {
${escapeHtml(t('返回仓库', 'Back to repository'))} - ${advanced ? `${escapeHtml(t('工作区', 'Workspace'))} + ${advanced && !supportMode ? `${escapeHtml(t('工作区', 'Workspace'))} ${escapeHtml(t('团队工作台', 'Team console'))}` : ''} - ${control} - ${execute} - ${retry} + ${supportMode ? '' : control} + ${supportMode ? '' : execute} + ${supportMode ? '' : retry}
- ${planCard} + ${supportMode ? '' : planCard}

${escapeHtml(t('结果', 'Result'))}

@@ -1626,3 +1628,31 @@ export function agentWorkspacePage(input: {

Fledge · powered by FlyBot

` return layout({ title: `${t('工作区', 'Workspace')} · ${repo.upstream_name || repo.name}`, viewer, body, config, locale, path: `/repos/${repo.id}/workspace`, active: 'dashboard', notice, error }) } + +export function adminSupportPage(input: { + config: Config + locale: Locale + viewer: Viewer + user: UserRow + repos: RepoRow[] + jobs: JobRow[] + audit: AuditRow[] + notice?: string + error?: string +}): string { + const { config, locale, viewer, user, repos, jobs, audit, notice, error } = input + const t = translator(locale) + const usersById = new Map([[user.id, user.login]]) + const repoRows = repos.length === 0 + ? `${escapeHtml(t('没有仓库。', 'No repositories.'))}` + : repos.map((repo) => `${escapeHtml(repo.upstream_owner || repo.owner)}/${escapeHtml(repo.upstream_name || repo.name)}${escapeHtml(repoRoleLabel(t, repo.repo_role))}${badge(t, repo.last_status ?? 'idle')}${escapeHtml(humanTimeShort(repo.last_run_at))}`).join('') + const jobRows = jobs.length === 0 + ? `${escapeHtml(t('没有任务。', 'No jobs.'))}` + : jobs.map((job) => `${escapeHtml(job.id)}${badge(t, job.status)} ${escapeHtml(jobKindLabel(t, job))}${escapeHtml(job.pr_url ?? '—')}${escapeHtml(humanTimeShort(job.created_at))}`).join('') + const body = `← ${escapeHtml(t('返回管理', 'Back to admin'))} +
${escapeHtml(t('支持视图', 'SUPPORT VIEW'))}

@${escapeHtml(user.login)}

${badge(t, user.status)} · ${escapeHtml(roleLabel(t, user.role))} · ${escapeHtml(t('加入于', 'Joined'))} ${escapeHtml(humanTimeShort(user.created_at))}

${escapeHtml(t('只读;不会修改用户数据或触发任务。', 'Read-only; no user data or jobs are changed.'))}

+

${escapeHtml(t('仓库', 'Repositories'))}

${repoRows}
${escapeHtml(t('仓库', 'Repository'))}${escapeHtml(t('角色', 'Role'))}${escapeHtml(t('状态', 'Status'))}${escapeHtml(t('上次运行', 'Last run'))}
+

${escapeHtml(t('最近任务', 'Recent jobs'))}

${jobRows}
${escapeHtml(t('任务', 'Job'))}${escapeHtml(t('状态', 'Status'))}PR${escapeHtml(t('创建', 'Created'))}
+

${escapeHtml(t('审计', 'Audit'))}

${auditRows(locale, audit, usersById, true)}
` + return layout({ title: `${t('支持视图', 'Support view')} @${user.login}`, viewer, body, config, locale, path: `/admin/users/${user.id}`, active: 'admin', notice, error }) +} diff --git a/test/admin.test.ts b/test/admin.test.ts index b1c1851..5df4bf7 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -201,3 +201,34 @@ test('the configured primary login is promoted to owner on startup', async () => await built.close() } }) + +test('admins can inspect a normal user read-only from a support view', 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 admin = makeUser(store, { login: 'support-admin', admin: true }) + const user = makeUser(store, { login: 'support-user', status: 'approved' }) + const otherAdmin = makeUser(store, { login: 'other-admin', admin: true }) + const repo = store.createRepo({ id: 'repo_support', userId: user.id, owner: 'o', name: 'support', defaultBranch: 'main', tokenEnc: 'x', permission: 'admin', repoRole: 'maintainer', goal: 'fix', autoIterate: false, intervalMinutes: 1440 }) + store.createJob({ id: 'job_support', repoId: repo.id, userId: user.id, trigger: 'manual', goal: 'support goal', callbackHash: 'cb', platformTokenHash: '', kind: 'iterate' }) + store.audit(user.id, 'repo.settings', 'support detail') + const { cookie } = sessionFor(built, admin) + const support = await built.app.inject({ method: 'GET', url: `/admin/users/${user.id}`, headers: { cookie } }) + assert.equal(support.statusCode, 200) + assert.match(support.body, /@support-user/) + assert.match(support.body, /只读/) + const job = await built.app.inject({ method: 'GET', url: '/admin/jobs/job_support', headers: { cookie } }) + assert.equal(job.statusCode, 200) + assert.match(job.body, /job_support/) + assert.doesNotMatch(job.body, /取消这个任务|重试这个任务|执行这个计划/) + const hidden = await built.app.inject({ method: 'GET', url: `/admin/users/${otherAdmin.id}`, headers: { cookie } }) + assert.equal(hidden.statusCode, 404) + const normal = makeUser(store, { login: 'normal-user', status: 'approved' }) + const normalSession = sessionFor(built, normal) + const denied = await built.app.inject({ method: 'GET', url: `/admin/users/${user.id}`, headers: { cookie: normalSession.cookie } }) + assert.equal(denied.statusCode, 403) + } finally { + await built.close() + } +})