diff --git a/src/server/runs/orchestrator.ts b/src/server/runs/orchestrator.ts index b35d513..b379538 100644 --- a/src/server/runs/orchestrator.ts +++ b/src/server/runs/orchestrator.ts @@ -48,6 +48,7 @@ import { type RunEvidenceStore, } from './evidence.js'; import { createProviderAdapter, type ProviderAdapter } from './provider.js'; +import { createIssueCandidateController, createRunHistoryTool } from './run-history.js'; import { createScenarioProgressController, type ProgressScenario } from './scenario-progress.js'; import { createReviewerTestDataTools, @@ -526,6 +527,10 @@ class DefaultRunOrchestrator implements RunOrchestrator { this.setPhase(state, 'main-a', 'Main · 规划正在分析变更并选择场景'); const tools = [ ...createTargetContextTools(this.targetToolOptions(repository, context, 'main-planning')), + createRunHistoryTool({ + runStore: this.options.runStore, + recoveryStore: this.options.recoveryStore, + }), createArtifactWriterTool( 'write_plan', '写入测试计划', @@ -590,6 +595,10 @@ class DefaultRunOrchestrator implements RunOrchestrator { this.setPhase(state, 'main-a', 'Main · 规划正在整理初始化候选场景'); const tools = [ ...createTargetContextTools(this.targetToolOptions(repository, context, 'main-planning')), + createRunHistoryTool({ + runStore: this.options.runStore, + recoveryStore: this.options.recoveryStore, + }), createReadArtifactTool((name) => readAllowedArtifact(workspace, name, ['plan.md', 'execution.md', 'draft-report.md']), ), @@ -1091,16 +1100,24 @@ class DefaultRunOrchestrator implements RunOrchestrator { context: RunContext, ): Promise { this.setPhase(state, 'main-b', 'Main · 最终汇总正在汇总最终报告'); + const artifactsRead = new Set(); + const issueCandidates = createIssueCandidateController( + { runStore: this.options.runStore, repository: this.options.repository }, + () => artifactsRead.has('draft-report.md') && artifactsRead.has('review.md'), + ); const tools = [ - createReadArtifactTool((name) => - readAllowedArtifact(workspace, name, [ + createReadArtifactTool(async (name) => { + const content = await readAllowedArtifact(workspace, name, [ 'plan.md', 'execution.md', 'draft-report.md', 'review.md', ...(context.initialization ? ['scenario-changes.patch' as const] : []), - ]), - ), + ]); + artifactsRead.add(name); + return content; + }), + issueCandidates.tool, createArtifactWriterTool( 'write_report', '写入最终报告', @@ -1129,6 +1146,27 @@ class DefaultRunOrchestrator implements RunOrchestrator { context.initialization, ); await assertArtifact(workspace, 'report.md'); + const reportContent = await workspace.read('report.md'); + const report = parseReportMarkdown( + reportContent, + `${workspace.runningDirectory}/report.md`, + state.runId, + ); + for (const bug of report.confirmedBugs) { + const coverage = issueCandidates.coverageForBug(bug.key, bug.title); + if (coverage === 'none') { + throw new RunOrchestratorError( + 'RUN_ARTIFACT_INVALID', + 'Main · 最终汇总必须为每个 confirmed Bug 先查询对应的相似 Issue 候选', + ); + } + if (coverage === 'gap' && !hasIssueCoverageGap(reportContent, bug.key)) { + throw new RunOrchestratorError( + 'RUN_ARTIFACT_INVALID', + 'Issue 候选查询 unavailable 或预算耗尽时必须在报告记录覆盖缺口', + ); + } + } } private async invoke( @@ -1654,6 +1692,14 @@ function aggregateResult(results: RunResult[], hasConfirmedBug: boolean): RunRes return 'passed'; } +function hasIssueCoverageGap(content: string, bugKey: string): boolean { + const body = content + .split(/^---\s*$/m) + .slice(2) + .join('\n---\n'); + return body.includes('## Issue 查询覆盖缺口') && body.includes(bugKey); +} + function hasZeroScenarioEvidence(content: string): boolean { return /无需\s*场景|零场景|no\s+scenarios?|no\s+scenario\s+testing|does\s+not\s+require\s+(?:a\s+)?scenario/i.test( content, @@ -1745,13 +1791,13 @@ function mainAOutputContract(context: RunContext): string { const patchInstruction = context.initialization ? '本阶段只写 plan.md,不写 scenario-changes.patch;运行时侦察后由新的 Main · 规划 Session 生成候选 patch。' : '如需维护长期场景,只能通过 write_scenario_patch 写场景目录内的标准 git unified patch。'; - return `必须先调用 get_run_context、list_target_files,并按需调用 read_target_file/search_target_files。必须在结束前通过 write_plan 写入完整 plan.md;historyIssuesAvailable=false 时在覆盖缺口中说明。plan.md 中每个实际执行场景必须写出当前工作场景的稳定 ID。 + return `必须先调用 get_run_context、list_target_files,并按需调用 read_target_file/search_target_files;需要历史判断时只通过 query_run_history 查询有限、脱敏的 Run 摘要。必须在结束前通过 write_plan 写入完整 plan.md;historyIssuesAvailable=false 时在覆盖缺口中说明。plan.md 中每个实际执行场景必须写出当前工作场景的稳定 ID。 ${patchInstruction} 如果确有依据判断无需测试,明确写出“无需场景测试”的理由;否则保留场景缺失、影响不明或证据不足的覆盖缺口。`; } function initializationCandidateUserMessage(context: RunContext): string { - return `当前任务:在新的 Main · 规划 Session 中,综合静态证据和低风险运行时侦察,形成少量高价值候选场景。 + return `当前任务:在新的 Main · 规划 Session 中,综合静态证据和低风险运行时侦察,形成少量高价值候选场景;需要历史判断时只通过 query_run_history 查询有限、脱敏的 Run 摘要。 动态 Run 上下文: ${JSON.stringify(mainPlanningContext(context), null, 2)}`; @@ -1801,6 +1847,28 @@ function reviewerOutputContract(): string { 截图不可访问、上传失败、视觉能力不足、清理未确认、场景缺失或影响不明时维持 blocked。零场景只有在 Main · 规划的计划确有依据时才能确认。结束前通过 write_review 写完整 review.md,并明确是否同意最终结果。`; } +function finalizationPromptContext(context: RunContext): Record { + return { + runId: context.runId, + request: context.request, + trigger: context.trigger, + baseCommit: context.baseCommit, + targetCommit: context.targetCommit, + includedCommits: context.includedCommits, + scenarioMode: context.scenarioMode, + initialization: context.initialization, + scenarioChanges: context.scenarioChanges ?? null, + evidence: context.evidence.map(({ filename, url, contentType, sizeBytes, sha256 }) => ({ + filename, + url, + contentType, + sizeBytes, + sha256, + })), + blockingReasons: context.blockingReasons, + }; +} + function mainBUserMessage(context: RunContext): string { const task = context.initialization ? '汇总初始化 Run;可在 Reviewer 意见支持下用受限 writer 修订尚未发布的候选场景 patch,但修订后未重新执行必须保持 blocked。' @@ -1808,11 +1876,11 @@ function mainBUserMessage(context: RunContext): string { return `当前任务:${task} 动态 Run 上下文: -${JSON.stringify(finalizationContext(context), null, 2)}`; +${JSON.stringify(finalizationPromptContext(context), null, 2)}`; } function mainBOutputContract(): string { - return `必须先读取 plan.md、execution.md、draft-report.md、review.md;初始化且存在 scenario-changes.patch 时也读取它。最终 report.md frontmatter 只能包含 run_id、trigger、base_commit、target_commit、included_commits、result、started_at、finished_at、scenario_results、confirmed_bugs,字段值必须与固定 Run 一致。result 优先级为 blocked > failed > passed;blockingReasons 非空时必须 blocked。 + return `必须先读取 plan.md、execution.md、draft-report.md、review.md;初始化且存在 scenario-changes.patch 时也读取它。读取草稿和审核后,必须为每个本次 confirmed Bug 按 title、keywords 或 bug_key 调用 query_issue_candidates;严格区分 ok、empty、unavailable,unavailable 最多原样重试一次。查询 unavailable、重试或预算耗尽时必须在正文写“## Issue 查询覆盖缺口”并列出对应 Bug key,不得伪装成 empty。最终 report.md frontmatter 只能包含 run_id、trigger、base_commit、target_commit、included_commits、result、started_at、finished_at、scenario_results、confirmed_bugs,字段值必须与固定 Run 一致。result 优先级为 blocked > failed > passed;blockingReasons 非空时必须 blocked。 scenario_results 必须是 YAML 数组,每项只能有 id 和 result。confirmed_bugs 每项只能有 key、title、scenario_ids、issue_action,以及 link 时必需的 issue_url;failed 至少有一个 confirmed bug,issue_action 只能 create 或 link。零场景 passed 必须在计划、审核和最终报告中都有“无需场景测试”依据。 证据只写在正文并引用稳定 URL。不得复述任何测试账号字段、Secret、隐藏推理、短期签名 URL 或绝对路径。结束前通过 write_report 写完整 report.md。`; } @@ -1861,18 +1929,3 @@ function reviewerContext(context: RunContext) { blockingReasons: context.blockingReasons, }; } - -function finalizationContext(context: RunContext) { - return { - runId: context.runId, - request: context.request, - trigger: context.trigger, - baseCommit: context.baseCommit, - targetCommit: context.targetCommit, - includedCommits: context.includedCommits, - scenarioMode: context.scenarioMode, - initialization: context.initialization, - evidence: context.evidence, - blockingReasons: context.blockingReasons, - }; -} diff --git a/src/server/runs/run-history.ts b/src/server/runs/run-history.ts new file mode 100644 index 0000000..cee5a57 --- /dev/null +++ b/src/server/runs/run-history.ts @@ -0,0 +1,586 @@ +import { Type, type Static } from 'typebox'; +import type { AgentToolResult, ToolDefinition } from '@earendil-works/pi-coding-agent'; + +import type { RepositoryIssue, RunResult } from '../../shared/types.js'; +import type { RunRecoveryStore } from '../automation/recovery.js'; +import type { RepositoryService } from '../repository/service.js'; +import { createTextResult } from './agent-session.js'; +import type { RunStore, StoredRun, StoredRunIssue } from './store.js'; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; +const MAX_FINALIZATION_CALLS = 10; +const SHA_PATTERN = /^[0-9a-f]{40}$/i; +const SCENARIO_ID_PATTERN = /^[A-Z0-9]+(?:-[A-Z0-9]+)+$/; + +export interface RunHistoryDependencies { + runStore?: RunStore; + recoveryStore?: RunRecoveryStore; +} + +export interface IssueCandidateDependencies { + runStore?: RunStore; + repository: Pick; +} + +export function createRunHistoryTool(dependencies: RunHistoryDependencies): ToolDefinition { + const parameters = Type.Object( + { + commit: Type.Optional(Type.String({ minLength: 40, maxLength: 40 })), + scenarioId: Type.Optional(Type.String({ maxLength: 128 })), + bugOrIssue: Type.Optional(Type.String({ maxLength: 256 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIMIT })), + }, + { additionalProperties: false }, + ); + return { + name: 'query_run_history', + label: '查询历史 Run', + description: + '只读查询 SQLite/Recovery 中有限、脱敏的历史 Run;可按 commit、场景或 Bug/Issue 过滤,默认最近 20 条,最多 100 条。', + parameters, + execute: async ( + _toolCallId: string, + params: Static, + ): Promise>> => { + try { + const query = validateRunHistoryQuery(params); + if (!dependencies.runStore && !dependencies.recoveryStore) { + return createTextResult( + JSON.stringify({ + status: 'unavailable', + runs: [], + message: 'Run 历史存储当前不可用', + }), + ); + } + let completed: StoredRun[]; + let interrupted: ReturnType; + try { + completed = dependencies.runStore?.list() ?? []; + interrupted = dependencies.recoveryStore?.list() ?? []; + } catch { + return createTextResult( + JSON.stringify({ + status: 'unavailable', + runs: [], + message: 'Run 历史依赖当前不可用', + }), + ); + } + const runs = [ + ...completed.map(summarizeStoredRun), + ...interrupted.map((run) => ({ + runId: run.runId, + status: 'interrupted' as const, + result: null, + trigger: run.trigger, + request: sanitizeText(run.request, 240), + baseCommit: run.baseCommit, + targetCommit: run.targetCommit, + includedCommits: [...run.includedCommits], + startedAt: run.startedAt, + finishedAt: run.finishedAt, + scenarioResults: [], + bugKeys: [], + issueUrls: [], + scenarioPrUrl: null, + reportStatus: 'not_applicable', + scenarioStatus: 'not_applicable', + archiveStatus: 'not_applicable', + errorMessage: sanitizeNullable(run.errorMessage, 500), + initialization: run.initialization === true, + specialBlocked: false, + interrupted: true, + })), + ] + .filter((run) => historyMatches(run, query)) + .sort(compareHistory) + .slice(0, query.limit); + return createTextResult(JSON.stringify({ status: runs.length > 0 ? 'ok' : 'empty', runs })); + } catch (error) { + return createTextResult(errorMessage(error), { error: true }); + } + }, + }; +} + +export interface IssueCandidateController { + tool: ToolDefinition; + callCount(): number; + coverageForBug(bugKey: string, title: string): 'covered' | 'gap' | 'none'; +} + +export function createIssueCandidateController( + dependencies: IssueCandidateDependencies, + canQuery: () => boolean, +): IssueCandidateController { + const parameters = Type.Object( + { + title: Type.Optional(Type.String({ maxLength: 200 })), + keywords: Type.Optional( + Type.Array(Type.String({ minLength: 2, maxLength: 64 }), { + minItems: 1, + maxItems: 8, + }), + ), + bug_key: Type.Optional(Type.String({ maxLength: 128 })), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_LIMIT })), + }, + { additionalProperties: false }, + ); + let calls = 0; + const queryResults: Array<{ + query: IssueCandidateQuery; + status: 'ok' | 'empty' | 'unavailable' | 'budget-exhausted'; + }> = []; + const previous = new Map(); + + const tool: ToolDefinition = { + name: 'query_issue_candidates', + label: '查询相似 Issue 候选', + description: + 'Main · 最终汇总在读取本次 draft/review 并形成 Bug 候选后,只读查询相似 Issue 和有限关联 Run。不会创建、修改、关闭或评论 Issue。', + parameters, + execute: async ( + _toolCallId: string, + params: Static, + ): Promise>> => { + try { + if (!canQuery()) + throw new Error('必须先读取 draft-report.md 和 review.md,再查询 Issue 候选'); + const query = validateIssueCandidateQuery(params); + const key = JSON.stringify(query); + const prior = previous.get(key); + if (prior?.status === 'ok' || prior?.status === 'empty') { + throw new Error('成功或空结果的相同 Issue 查询不能重复'); + } + if (prior?.status === 'unavailable' && prior.attempts >= 2) { + return createTextResult( + JSON.stringify({ + status: 'unavailable', + candidates: [], + message: '相同 unavailable 查询已达到一次重试上限', + }), + ); + } + if (calls >= MAX_FINALIZATION_CALLS) { + queryResults.push({ query, status: 'budget-exhausted' }); + return createTextResult( + JSON.stringify({ + status: 'unavailable', + candidates: [], + message: '本次最终汇总的 Issue 查询预算已耗尽', + }), + ); + } + calls += 1; + try { + if (!dependencies.runStore) throw new Error('run store unavailable'); + const issues = await dependencies.repository.listIssues(); + const candidates = findIssueCandidates(issues, dependencies.runStore.list(), query).slice( + 0, + query.limit, + ); + const status = candidates.length > 0 ? 'ok' : 'empty'; + previous.set(key, { status, attempts: (prior?.attempts ?? 0) + 1 }); + queryResults.push({ query, status }); + return createTextResult(JSON.stringify({ status, candidates })); + } catch { + previous.set(key, { + status: 'unavailable', + attempts: (prior?.attempts ?? 0) + 1, + }); + queryResults.push({ query, status: 'unavailable' }); + return createTextResult( + JSON.stringify({ + status: 'unavailable', + candidates: [], + message: 'Issue 或 Run 历史依赖当前不可用', + }), + ); + } + } catch (error) { + return createTextResult(errorMessage(error), { error: true }); + } + }, + }; + return { + tool, + callCount: () => calls, + coverageForBug: (bugKey, title) => { + const normalizedKey = normalize(bugKey); + const normalizedTitle = normalize(title); + const matching = queryResults.filter(({ query }) => + queryMatchesBug(query, normalizedKey, normalizedTitle), + ); + if (matching.some(({ status }) => status === 'ok' || status === 'empty')) return 'covered'; + if (matching.length > 0) return 'gap'; + return 'none'; + }, + }; +} + +interface RunHistoryQuery { + commit?: string; + scenarioId?: string; + bugOrIssue?: string; + limit: number; +} + +interface HistorySummary { + runId: string; + status: 'completed' | 'interrupted'; + result: RunResult | null; + trigger: string; + request: string; + baseCommit: string | null; + targetCommit: string | null; + includedCommits: string[]; + startedAt: string; + finishedAt: string | null; + scenarioResults: Array<{ id: string; result: RunResult }>; + bugKeys: string[]; + issueUrls: string[]; + scenarioPrUrl: string | null; + reportStatus: string; + scenarioStatus: string; + archiveStatus: string; + errorMessage: string | null; + initialization: boolean; + specialBlocked: boolean; + interrupted: boolean; +} + +interface IssueCandidateQuery { + title?: string; + keywords: string[]; + bugKey?: string; + limit: number; +} + +interface IssueCandidate { + number: number; + title: string; + url: string; + state: 'open' | 'closed'; + updatedAt: string; + matchReasons: string[]; + bugKeys: string[]; + relatedRuns: Array<{ + runId: string; + result: RunResult; + scenarioIds: string[]; + targetCommit: string; + finishedAt: string; + }>; +} + +interface RankedIssueCandidate extends IssueCandidate { + rank: { + exactBugKey: boolean; + exactTitle: boolean; + keywordHits: number; + }; +} + +function validateRunHistoryQuery(value: Record): RunHistoryQuery { + const commit = optionalString(value.commit, 40); + if (commit && !SHA_PATTERN.test(commit)) throw new Error('commit 必须是 40 位 SHA'); + const scenarioId = optionalString(value.scenarioId, 128); + if (scenarioId && !SCENARIO_ID_PATTERN.test(scenarioId)) throw new Error('scenarioId 格式无效'); + const bugOrIssue = optionalString(value.bugOrIssue, 256); + const limit = validateLimit(value.limit); + return { + ...(commit ? { commit: commit.toLowerCase() } : {}), + ...(scenarioId ? { scenarioId } : {}), + ...(bugOrIssue ? { bugOrIssue: normalize(bugOrIssue) } : {}), + limit, + }; +} + +function validateIssueCandidateQuery(value: Record): IssueCandidateQuery { + const title = optionalString(value.title, 200); + const bugKey = optionalString(value.bug_key, 128); + if (value.keywords !== undefined && !Array.isArray(value.keywords)) { + throw new Error('keywords 必须是字符串数组'); + } + const rawKeywords = (value.keywords ?? []) as unknown[]; + if ( + (value.keywords !== undefined && rawKeywords.length === 0) || + rawKeywords.length > 8 || + rawKeywords.some((keyword) => typeof keyword !== 'string') + ) { + throw new Error('keywords 必须包含 1–8 个字符串'); + } + const keywords = [ + ...new Set(rawKeywords.map((keyword) => normalize(assertString(keyword, 64))).filter(Boolean)), + ].sort(); + if (keywords.some((keyword) => keyword.length < 2)) { + throw new Error('每个 keyword 必须为 2–64 个字符'); + } + if (!title && !bugKey && keywords.length === 0) { + throw new Error('title、keywords、bug_key 至少提供一个'); + } + for (const value of [title, bugKey, ...keywords]) { + if (value && (containsControl(value) || looksLikeSecret(value))) { + throw new Error('Issue 查询条件包含控制字符或敏感凭据形态'); + } + } + return { + ...(title ? { title: normalize(title) } : {}), + keywords, + ...(bugKey ? { bugKey: normalize(bugKey) } : {}), + limit: validateLimit(value.limit), + }; +} + +function queryMatchesBug( + query: IssueCandidateQuery, + normalizedKey: string, + normalizedTitle: string, +): boolean { + return ( + query.bugKey === normalizedKey || + (query.title !== undefined && + (query.title.includes(normalizedTitle) || normalizedTitle.includes(query.title))) || + query.keywords.some( + (keyword) => normalizedKey.includes(keyword) || normalizedTitle.includes(keyword), + ) + ); +} + +function summarizeStoredRun(run: StoredRun): HistorySummary { + return { + runId: run.runId, + status: 'completed', + result: run.result, + trigger: run.trigger, + request: sanitizeText(run.request, 240), + baseCommit: run.baseCommit, + targetCommit: run.targetCommit, + includedCommits: [...run.includedCommits], + startedAt: run.startedAt, + finishedAt: run.finishedAt, + scenarioResults: run.scenarioResults.map((item) => ({ ...item })), + bugKeys: run.issues.map((issue) => sanitizeText(issue.bugKey, 128)), + issueUrls: unique( + run.issues.map((issue) => issue.issueUrl).filter((url): url is string => Boolean(url)), + ), + scenarioPrUrl: run.scenarioPrUrl, + reportStatus: run.reportStatus, + scenarioStatus: run.scenarioStatus, + archiveStatus: run.archiveStatus, + errorMessage: sanitizeNullable( + run.archiveError ?? + run.scenarioError ?? + run.issues.find((issue) => issue.errorMessage)?.errorMessage, + 500, + ), + initialization: run.initialization, + specialBlocked: run.specialRun && run.result === 'blocked', + interrupted: false, + }; +} + +function historyMatches(run: HistorySummary, query: RunHistoryQuery): boolean { + if ( + query.commit && + ![run.baseCommit, run.targetCommit, ...run.includedCommits].some( + (commit) => commit?.toLowerCase() === query.commit, + ) + ) { + return false; + } + if ( + query.scenarioId && + !run.scenarioResults.some((scenario) => scenario.id === query.scenarioId) + ) { + return false; + } + if (query.bugOrIssue) { + const values = [...run.bugKeys, ...run.issueUrls].map(normalize); + if (!values.some((value) => value.includes(query.bugOrIssue as string))) return false; + } + return true; +} + +function compareHistory(left: HistorySummary, right: HistorySummary): number { + const leftTime = left.finishedAt ?? left.startedAt; + const rightTime = right.finishedAt ?? right.startedAt; + return rightTime.localeCompare(leftTime) || right.runId.localeCompare(left.runId); +} + +function findIssueCandidates( + issues: readonly RepositoryIssue[], + runs: readonly StoredRun[], + query: IssueCandidateQuery, +): IssueCandidate[] { + const result: RankedIssueCandidate[] = []; + for (const issue of issues) { + const related = relationsForIssue(issue, runs); + const normalizedTitles = [ + normalize(issue.title), + ...related.map((item) => normalize(item.issue.title)), + ]; + const exactBugKey = Boolean( + query.bugKey && related.some((item) => normalize(item.issue.bugKey) === query.bugKey), + ); + const exactTitle = Boolean( + query.title && normalizedTitles.some((title) => title === query.title), + ); + const containsTitle = Boolean( + query.title && + normalizedTitles.some( + (title) => title.includes(query.title as string) || query.title?.includes(title), + ), + ); + const keywordText = normalize( + [issue.title, ...related.flatMap((item) => [item.issue.title, item.issue.bugKey])].join(' '), + ); + const keywordHits = query.keywords.filter((keyword) => keywordText.includes(keyword)).length; + if (!exactBugKey && !exactTitle && !containsTitle && keywordHits === 0) continue; + const relatedRuns = uniqueRelatedRuns(related); + result.push({ + number: issue.number, + title: sanitizeText(issue.title, 200), + url: issue.url, + state: issue.state, + updatedAt: issue.updatedAt, + matchReasons: [ + ...(exactBugKey ? ['exact_bug_key'] : []), + ...(exactTitle ? ['exact_title'] : containsTitle ? ['contains_title'] : []), + ...(keywordHits > 0 ? [`keyword_hits:${keywordHits}`] : []), + ], + bugKeys: unique(related.map((item) => sanitizeText(item.issue.bugKey, 128))), + relatedRuns, + rank: { exactBugKey, exactTitle, keywordHits }, + }); + } + return result + .sort( + (left, right) => + Number(right.rank.exactBugKey) - Number(left.rank.exactBugKey) || + Number(right.rank.exactTitle) - Number(left.rank.exactTitle) || + right.rank.keywordHits - left.rank.keywordHits || + right.updatedAt.localeCompare(left.updatedAt) || + right.number - left.number, + ) + .map((candidate) => ({ + number: candidate.number, + title: candidate.title, + url: candidate.url, + state: candidate.state, + updatedAt: candidate.updatedAt, + matchReasons: candidate.matchReasons, + bugKeys: candidate.bugKeys, + relatedRuns: candidate.relatedRuns, + })); +} + +function relationsForIssue( + issue: RepositoryIssue, + runs: readonly StoredRun[], +): Array<{ run: StoredRun; issue: StoredRunIssue }> { + return runs.flatMap((run) => + run.issues + .filter( + (stored) => + stored.issueNumber === issue.number || + sameUrl(stored.issueUrl, issue.url) || + sameUrl(stored.requestedIssueUrl, issue.url), + ) + .map((stored) => ({ run, issue: stored })), + ); +} + +function uniqueRelatedRuns( + relations: Array<{ run: StoredRun; issue: StoredRunIssue }>, +): IssueCandidate['relatedRuns'] { + const byRun = new Map(); + for (const { run, issue } of relations) { + const existing = byRun.get(run.runId); + const scenarioIds = unique([...(existing?.scenarioIds ?? []), ...issue.scenarioIds]).sort(); + byRun.set(run.runId, { + runId: run.runId, + result: run.result, + scenarioIds, + targetCommit: run.targetCommit, + finishedAt: run.finishedAt, + }); + } + return [...byRun.values()].sort( + (left, right) => + right.finishedAt.localeCompare(left.finishedAt) || right.runId.localeCompare(left.runId), + ); +} + +function validateLimit(value: unknown): number { + if (value === undefined) return DEFAULT_LIMIT; + if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > MAX_LIMIT) { + throw new Error('limit 必须是 1–100 的整数'); + } + return value as number; +} + +function optionalString(value: unknown, maxLength: number): string | undefined { + if (value === undefined) return undefined; + const result = assertString(value, maxLength).trim(); + if (result === '') return undefined; + if (containsControl(result)) throw new Error('查询文本不能包含控制字符'); + return result; +} + +function assertString(value: unknown, maxLength: number): string { + if (typeof value !== 'string' || value.length > maxLength) + throw new Error('查询文本类型或长度无效'); + return value; +} + +function normalize(value: string): string { + return value.normalize('NFKC').toLocaleLowerCase().replace(/\s+/g, ' ').trim(); +} + +function containsControl(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }); +} + +function sanitizeNullable(value: string | null | undefined, maxLength: number): string | null { + return value ? sanitizeText(value, maxLength) : null; +} + +function sanitizeText(value: string, maxLength: number): string { + return value + .replace(/[\r\n\t]/g, ' ') + .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?[^,;\s}]+/gi, '$1[REDACTED]') + .replace( + /((?:password|passwd|token|secret|cookie|api[-_]?key)\s*[:=]\s*)[^,;\s}]+/gi, + '$1[REDACTED]', + ) + .replace(/\b(?:github_pat_|gh[opsur]_|sk-)[A-Za-z0-9_-]+\b/g, '[REDACTED]') + .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); +} + +function looksLikeSecret(value: string): boolean { + return /(?:authorization\s*[:=]|password\s*[:=]|token\s*[:=]|secret\s*[:=])|\b(?:github_pat_|gh[opsur]_|sk-)|\bAKIA[0-9A-Z]{16}/i.test( + value, + ); +} + +function sameUrl(left: string | null, right: string): boolean { + return left?.replace(/\/$/, '') === right.replace(/\/$/, ''); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : '历史查询失败'; +} diff --git a/tests/closure5-history.test.ts b/tests/closure5-history.test.ts new file mode 100644 index 0000000..2760674 --- /dev/null +++ b/tests/closure5-history.test.ts @@ -0,0 +1,401 @@ +import { strict as assert } from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, it } from 'vitest'; +import type { AgentToolResult, ToolDefinition } from '@earendil-works/pi-coding-agent'; + +import { loadConfig } from '../src/server/config.js'; +import { initializeDatabase } from '../src/server/db/migrate.js'; +import { createRunRecoveryStore } from '../src/server/automation/recovery.js'; +import { + createIssueCandidateController, + createRunHistoryTool, +} from '../src/server/runs/run-history.js'; +import { createRunStore, type RunStore } from '../src/server/runs/store.js'; +import type { RepositoryIssue, RunSummary } from '../src/shared/types.js'; + +const cleanup: Array<() => Promise> = []; +const TARGET_A = 'a'.repeat(40); +const TARGET_B = 'b'.repeat(40); +const TARGET_C = 'c'.repeat(40); + +afterEach(async () => { + while (cleanup.length > 0) await cleanup.pop()?.(); +}); + +describe('Closure 5 bounded history tools', () => { + it('queries completed, special blocked, archive-failed, and interrupted Runs with stable limited summaries', async () => { + const fixture = await historyFixture(); + const tool = createRunHistoryTool({ + runStore: fixture.runStore, + recoveryStore: fixture.recoveryStore, + }); + + const recent = await invokeJson(tool, { limit: 100 }); + assert.equal(recent.status, 'ok'); + const runs = recent.runs as Array>; + assert.deepEqual( + runs.map((run) => run.runId), + [ + '01K00000000000000000000004', + '01K00000000000000000000003', + '01K00000000000000000000002', + '01K00000000000000000000001', + ], + ); + assert.equal( + runs.find((run) => run.runId === '01K00000000000000000000002')?.specialBlocked, + true, + ); + assert.equal( + runs.find((run) => run.runId === '01K00000000000000000000002')?.scenarioPrUrl, + 'https://github.com/example/app/pull/8', + ); + assert.equal( + runs.find((run) => run.runId === '01K00000000000000000000003')?.archiveStatus, + 'failed', + ); + assert.equal(runs[0]?.status, 'interrupted'); + const serialized = JSON.stringify(recent); + assert.doesNotMatch(serialized, /full secret artifact|request-secret|archive-secret/); + assert.match(serialized, /REDACTED/); + + const byScenario = await invokeJson(tool, { scenarioId: 'AUTH-LOGIN-001' }); + assert.deepEqual( + (byScenario.runs as Array<{ runId: string }>).map((run) => run.runId), + ['01K00000000000000000000003', '01K00000000000000000000001'], + ); + const byCommit = await invokeJson(tool, { commit: TARGET_B, limit: 1 }); + assert.equal( + (byCommit.runs as Array<{ runId: string }>)[0]?.runId, + '01K00000000000000000000004', + ); + const byBug = await invokeJson(tool, { bugOrIssue: 'BUG-AUTH-001' }); + assert.deepEqual( + (byBug.runs as Array<{ runId: string }>).map((run) => run.runId), + ['01K00000000000000000000001'], + ); + const empty = await invokeJson(tool, { scenarioId: 'NO-SUCH-001' }); + assert.equal(empty.status, 'empty'); + }); + + it('matches Issue candidates by normalized title, exact bug key, and keyword count with stable ordering', async () => { + const fixture = await historyFixture(); + const issues: RepositoryIssue[] = [ + issue(12, '登录 状态 丢失', '2026-09-01T03:00:00.000Z'), + issue(13, 'Login timeout regression', '2026-09-01T04:00:00.000Z'), + issue(14, 'Login regression', '2026-09-01T04:00:00.000Z'), + ]; + const repository = { listIssues: async () => issues }; + + const byBug = createIssueCandidateController( + { runStore: fixture.runStore, repository }, + () => true, + ); + const bugResult = await invokeJson(byBug.tool, { bug_key: ' bug-auth-001 ' }); + assert.equal(bugResult.status, 'ok'); + const bugCandidates = bugResult.candidates as Array>; + assert.equal(bugCandidates[0]?.number, 12); + assert.deepEqual(bugCandidates[0]?.matchReasons, ['exact_bug_key']); + assert.deepEqual( + (bugCandidates[0]?.relatedRuns as Array<{ runId: string }>).map((run) => run.runId), + ['01K00000000000000000000001'], + ); + assert.equal(byBug.coverageForBug('BUG-AUTH-001', '登录状态丢失'), 'covered'); + assert.equal(byBug.coverageForBug('BUG-OTHER-001', '其他问题'), 'none'); + + const byTitle = createIssueCandidateController( + { runStore: fixture.runStore, repository }, + () => true, + ); + const titleResult = await invokeJson(byTitle.tool, { title: ' 登录 状态 丢失 ' }); + assert.equal((titleResult.candidates as Array>)[0]?.number, 12); + assert.deepEqual((titleResult.candidates as Array>)[0]?.matchReasons, [ + 'exact_title', + ]); + + const byKeywords = createIssueCandidateController( + { runStore: fixture.runStore, repository }, + () => true, + ); + const keywordResult = await invokeJson(byKeywords.tool, { + keywords: ['LOGIN', ' timeout ', 'login'], + }); + assert.deepEqual( + (keywordResult.candidates as Array<{ number: number }>).map((candidate) => candidate.number), + [13, 14], + ); + assert.doesNotMatch(JSON.stringify(keywordResult), /full secret artifact|request-secret/); + }); + + it('enforces read-before-query, strict input, duplicate, retry, and ten-call budgets', async () => { + const fixture = await historyFixture(); + let available = false; + let repositoryCalls = 0; + const unavailable = createIssueCandidateController( + { + runStore: fixture.runStore, + repository: { + listIssues: async () => { + repositoryCalls += 1; + throw new Error('credential-bearing dependency failure'); + }, + }, + }, + () => available, + ); + const beforeRead = await invoke(unavailable.tool, { title: '登录问题' }); + assert.equal(beforeRead.details.error, true); + available = true; + for (let attempt = 0; attempt < 3; attempt += 1) { + const result = await invokeJson(unavailable.tool, { title: '登录问题' }); + assert.equal(result.status, 'unavailable'); + assert.doesNotMatch(JSON.stringify(result), /credential-bearing/); + } + assert.equal(repositoryCalls, 2); + assert.equal(unavailable.coverageForBug('BUG-LOGIN-001', '登录问题'), 'gap'); + + const invalid = createIssueCandidateController( + { runStore: fixture.runStore, repository: { listIssues: async () => [] } }, + () => true, + ); + for (const params of [ + {}, + { keywords: 'not-an-array' }, + { keywords: [] }, + { keywords: ['x'] }, + { title: 'x'.repeat(201) }, + { title: 'bad\ncontrol' }, + { title: `token=${'sensitive-value'}` }, + { limit: 101, title: 'valid title' }, + ]) { + const result = await invoke(invalid.tool, params); + assert.equal(result.details.error, true, JSON.stringify(params)); + } + + const budget = createIssueCandidateController( + { runStore: fixture.runStore, repository: { listIssues: async () => [] } }, + () => true, + ); + for (let index = 0; index < 10; index += 1) { + const result = await invokeJson(budget.tool, { title: `unique title ${index}` }); + assert.equal(result.status, 'empty'); + } + const exhausted = await invokeJson(budget.tool, { title: 'eleventh unique title' }); + assert.equal(exhausted.status, 'unavailable'); + assert.match(String(exhausted.message), /预算已耗尽/); + assert.equal(budget.coverageForBug('BUG-11', 'eleventh unique title'), 'gap'); + + const duplicate = createIssueCandidateController( + { runStore: fixture.runStore, repository: { listIssues: async () => [] } }, + () => true, + ); + assert.equal((await invokeJson(duplicate.tool, { title: 'same title' })).status, 'empty'); + const repeated = await invoke(duplicate.tool, { title: ' same title ' }); + assert.equal(repeated.details.error, true); + }); + + it('distinguishes successful empty from unavailable dependencies', async () => { + const fixture = await historyFixture(); + const empty = createIssueCandidateController( + { runStore: fixture.runStore, repository: { listIssues: async () => [] } }, + () => true, + ); + assert.deepEqual(await invokeJson(empty.tool, { title: 'nothing matches' }), { + status: 'empty', + candidates: [], + }); + + const missingStore = createIssueCandidateController( + { repository: { listIssues: async () => [] } }, + () => true, + ); + const unavailable = await invokeJson(missingStore.tool, { title: 'nothing matches' }); + assert.equal(unavailable.status, 'unavailable'); + assert.deepEqual(unavailable.candidates, []); + }); +}); + +async function historyFixture(): Promise<{ + runStore: RunStore; + recoveryStore: ReturnType; +}> { + const directory = await mkdtemp(join(tmpdir(), 'luowang-closure5-')); + cleanup.push(async () => rm(directory, { recursive: true, force: true })); + const config = loadConfig({ + NODE_ENV: 'test', + LUOWANG_DATA_DIR: directory, + LUOWANG_ADMIN_PASSWORD: 'closure5-fixture-password!', + LUOWANG_MASTER_KEY: 'closure5-fixture-master-key', + }); + const database = initializeDatabase(config); + cleanup.push(async () => database.close()); + const runStore = createRunStore(database.sqlite, { now: () => '2026-09-01T05:00:00.000Z' }); + const recoveryStore = createRunRecoveryStore(database.sqlite, { + now: () => '2026-09-01T05:00:00.000Z', + }); + + importRun(runStore, { + runId: '01K00000000000000000000001', + result: 'failed', + targetCommit: TARGET_A, + includedCommits: [], + finishedAt: '2026-09-01T01:00:00.000Z', + scenarios: [{ id: 'AUTH-LOGIN-001', result: 'failed' }], + bugs: [ + { + key: 'BUG-AUTH-001', + title: '登录状态丢失', + scenarioIds: ['AUTH-LOGIN-001'], + issueAction: 'create', + }, + ], + request: `token=${'request-secret'}`, + }); + runStore.markIssueAttempt('01K00000000000000000000001', 'BUG-AUTH-001', { + status: 'succeeded', + issueNumber: 12, + issueUrl: 'https://github.com/example/app/issues/12', + }); + + importRun(runStore, { + runId: '01K00000000000000000000002', + result: 'blocked', + targetCommit: TARGET_B, + includedCommits: [], + finishedAt: '2026-09-01T02:00:00.000Z', + scenarios: [], + bugs: [], + specialRun: true, + }); + runStore.markScenario('01K00000000000000000000002', { + status: 'pull_request', + scenarioPrUrl: 'https://github.com/example/app/pull/8', + }); + + importRun(runStore, { + runId: '01K00000000000000000000003', + result: 'passed', + targetCommit: TARGET_C, + includedCommits: [TARGET_B], + finishedAt: '2026-09-01T03:00:00.000Z', + scenarios: [{ id: 'AUTH-LOGIN-001', result: 'passed' }], + bugs: [ + { + key: 'BUG-AUTH-002', + title: 'Login timeout regression', + scenarioIds: ['AUTH-LOGIN-001'], + issueAction: 'link', + issueUrl: 'https://github.com/example/app/issues/13', + }, + ], + }); + runStore.markIssueAttempt('01K00000000000000000000003', 'BUG-AUTH-002', { + status: 'succeeded', + issueNumber: 13, + issueUrl: 'https://github.com/example/app/issues/13', + }); + runStore.markArchiveFailure('01K00000000000000000000003', `password=${'archive-secret'}`); + + const interrupted: RunSummary = { + runId: '01K00000000000000000000004', + status: 'interrupted', + phase: 'interrupted', + result: null, + trigger: 'schedule', + request: 'interrupted fixture', + baseCommit: TARGET_B, + targetCommit: TARGET_C, + includedCommits: [], + startedAt: '2026-09-01T03:30:00.000Z', + finishedAt: '2026-09-01T04:00:00.000Z', + errorMessage: 'process restarted', + artifactNames: ['plan.md'], + }; + recoveryStore.record(interrupted, { interruptedAt: interrupted.finishedAt ?? undefined }); + return { runStore, recoveryStore }; +} + +function importRun( + store: RunStore, + input: { + runId: string; + result: 'passed' | 'failed' | 'blocked'; + targetCommit: string; + includedCommits: string[]; + finishedAt: string; + scenarios: Array<{ id: string; result: 'passed' | 'failed' | 'blocked' }>; + bugs: Array<{ + key: string; + title: string; + scenarioIds: string[]; + issueAction: 'create' | 'link'; + issueUrl?: string; + }>; + request?: string; + specialRun?: boolean; + }, +): void { + store.importCompleted({ + runId: input.runId, + trigger: 'manual', + request: input.request ?? 'fixture request', + baseCommit: null, + targetCommit: input.targetCommit, + includedCommits: input.includedCommits, + result: input.result, + startedAt: input.finishedAt.replace(/:00\.000Z$/, ':00.000Z'), + finishedAt: input.finishedAt, + completedDirectory: `/tmp/${input.runId}`, + artifacts: { + 'plan.md': 'full secret artifact', + 'execution.md': 'fixture execution', + 'draft-report.md': 'fixture draft', + 'review.md': 'fixture review', + 'report.md': 'fixture report', + ...(input.specialRun ? { 'scenario-changes.patch': 'fixture patch' } : {}), + }, + scenarioResults: input.scenarios, + confirmedBugs: input.bugs, + specialRun: input.specialRun, + }); +} + +function issue(number: number, title: string, updatedAt: string): RepositoryIssue { + return { + number, + title, + state: 'open', + url: `https://github.com/example/app/issues/${number}`, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt, + }; +} + +async function invokeJson( + tool: ToolDefinition, + params: Record, +): Promise> { + const result = await invoke(tool, params); + assert.notEqual(result.details.error, true, textOf(result)); + return JSON.parse(textOf(result)) as Record; +} + +async function invoke( + tool: ToolDefinition, + params: Record, +): Promise>> { + return tool.execute( + 'closure5-tool', + params as never, + undefined, + undefined, + {} as never, + ) as Promise>>; +} + +function textOf(result: AgentToolResult>): string { + return result.content.map((item) => (item.type === 'text' ? item.text : '')).join(''); +} diff --git a/tests/phase3.test.ts b/tests/phase3.test.ts index 9bcbad2..b04694b 100644 --- a/tests/phase3.test.ts +++ b/tests/phase3.test.ts @@ -213,6 +213,27 @@ describe('Phase 3 agent run', () => { await invokeTool(context.sessions.inputs[1] as AgentSessionInput, 'get_run_context', {}), ); assert.doesNotMatch(runnerToolContext, /历史登录问题|historyIssues|indexedReports/); + const [planning, runner, reviewer, finalization] = context.sessions.inputs; + assert.ok(planning?.customTools.some((tool) => tool.name === 'query_run_history')); + assert.equal( + planning?.customTools.some((tool) => tool.name === 'query_issue_candidates'), + false, + ); + for (const input of [runner, reviewer]) { + assert.equal( + input?.customTools.some((tool) => tool.name === 'query_run_history'), + false, + ); + assert.equal( + input?.customTools.some((tool) => tool.name === 'query_issue_candidates'), + false, + ); + } + assert.ok(finalization?.customTools.some((tool) => tool.name === 'query_issue_candidates')); + assert.equal( + finalization?.customTools.some((tool) => tool.name === 'query_run_history'), + false, + ); }); it('publishes a real two-scenario Runner progression from 0/2 to 2/2', async () => { @@ -569,6 +590,9 @@ class RecordingSessionFactory implements AgentSessionFactory { } const outcome = this.outcomes[Math.min(this.outcomeIndex - 1, this.outcomes.length - 1)] ?? 'passed'; + if (outcome === 'failed') { + await invokeTool(input, 'query_issue_candidates', { bug_key: 'BUG-LOGIN-001' }); + } if (outcome === 'passed') { await invokeTool(input, 'write_report', { content: this.progress @@ -710,7 +734,11 @@ confirmed_bugs: ${bugs} # Report -${result === 'passed' ? '无需场景测试:Reviewer 已独立确认本批不影响产品行为。' : '证据记录见 execution.md 和 review.md。'} +${ + result === 'passed' + ? '无需场景测试:Reviewer 已独立确认本批不影响产品行为。' + : `证据记录见 execution.md 和 review.md。${failedBug ? '\n\n## Issue 查询覆盖缺口\n\n- BUG-LOGIN-001:unavailable' : ''}` +} `; }