|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | + |
| 6 | +import { RunOnceScheduler } from '../../../base/common/async.js'; |
| 7 | +import { VSBuffer } from '../../../base/common/buffer.js'; |
| 8 | +import { Event } from '../../../base/common/event.js'; |
| 9 | +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; |
| 10 | +import { LRUCache } from '../../../base/common/map.js'; |
| 11 | +import { escapeRegExpCharacters } from '../../../base/common/strings.js'; |
| 12 | +import { generateUuid } from '../../../base/common/uuid.js'; |
| 13 | +import { GitHubWorkflowJob, GitHubWorkflowLog } from '../../github/common/githubPullRequestMutationService.js'; |
| 14 | +import { AgentMergeCIRequest } from './shared/agentMergeServerTools.js'; |
| 15 | + |
| 16 | +export const agentMergeCIResponseBytes = 48_000; |
| 17 | +const evidenceLifetime = 5 * 60_000; |
| 18 | +const maximumEvidenceEntries = 8; |
| 19 | +const maximumEvidenceCharacters = 32 * 1024 * 1024; |
| 20 | +const excerptBytes = 6_000; |
| 21 | +const lineIndexStride = 1_024; |
| 22 | + |
| 23 | +export interface AgentMergeCIEvidence { |
| 24 | + readonly id: string; |
| 25 | + readonly scope: string; |
| 26 | + readonly runAttempt: number; |
| 27 | + readonly job: GitHubWorkflowJob; |
| 28 | + readonly log: GitHubWorkflowLog; |
| 29 | + readonly lineCount: number; |
| 30 | + /** One UTF-16 offset per 1,024 lines bounds seeking without a full per-line index. */ |
| 31 | + readonly lineStartOffsets: Uint32Array; |
| 32 | + readonly expiresAt: number; |
| 33 | +} |
| 34 | + |
| 35 | +export interface AgentMergeCIContinuation { |
| 36 | + readonly request: AgentMergeCIRequest; |
| 37 | + readonly summaryOffset?: number; |
| 38 | + readonly signature?: string; |
| 39 | +} |
| 40 | + |
| 41 | +/** Holds only redacted, immutable evidence; authorization is rechecked by the tool before every use. */ |
| 42 | +export class AgentMergeCIEvidenceStore extends Disposable { |
| 43 | + private readonly _entries = new Map<string, AgentMergeCIEvidence>(); |
| 44 | + private readonly _listeners = this._register(new DisposableMap<string>()); |
| 45 | + private readonly _cursors = new LRUCache<string, AgentMergeCIContinuation & { scope: string; expiresAt: number }>(128); |
| 46 | + private readonly _expiry = this._register(new RunOnceScheduler(() => this._prune(), evidenceLifetime)); |
| 47 | + private _characters = 0; |
| 48 | + |
| 49 | + find(scope: string, jobId: string, runId: string, runAttempt: number): AgentMergeCIEvidence | undefined { |
| 50 | + this._prune(); |
| 51 | + const entry = [...this._entries.values()].find(entry => entry.scope === scope && entry.job.id === jobId && entry.job.runId === runId && entry.runAttempt === runAttempt); |
| 52 | + if (!entry) { |
| 53 | + return undefined; |
| 54 | + } |
| 55 | + // Reused evidence must not expire while its new summary is still being assembled. |
| 56 | + const refreshed = { ...entry, expiresAt: Date.now() + evidenceLifetime }; |
| 57 | + this._entries.delete(entry.id); |
| 58 | + this._entries.set(entry.id, refreshed); |
| 59 | + return refreshed; |
| 60 | + } |
| 61 | + |
| 62 | + get(id: string, scope: string): AgentMergeCIEvidence { |
| 63 | + this._prune(); |
| 64 | + const entry = this._entries.get(id); |
| 65 | + if (!entry || entry.scope !== scope) { |
| 66 | + throw new Error('Invalid, expired, or unauthorized CI evidence ID. Read a new summary for the active Agent Merge turn.'); |
| 67 | + } |
| 68 | + this._entries.delete(id); |
| 69 | + this._entries.set(id, entry); |
| 70 | + return entry; |
| 71 | + } |
| 72 | + |
| 73 | + canAdd(retainedIds: ReadonlySet<string>, characters = 0): boolean { |
| 74 | + const retained = [...this._entries.values()].filter(entry => retainedIds.has(entry.id)); |
| 75 | + return retained.length < maximumEvidenceEntries |
| 76 | + && retained.reduce((total, entry) => total + entry.log.text.length, characters) <= maximumEvidenceCharacters; |
| 77 | + } |
| 78 | + |
| 79 | + /** Returns undefined when the log cannot fit without evicting evidence already advertised on this page. */ |
| 80 | + tryAdd(scope: string, runAttempt: number, job: GitHubWorkflowJob, log: GitHubWorkflowLog, signal: AbortSignal, retainedIds: ReadonlySet<string> = new Set()): AgentMergeCIEvidence | undefined { |
| 81 | + signal.throwIfAborted(); |
| 82 | + if (this._store.isDisposed || log.text.length > maximumEvidenceCharacters) { |
| 83 | + throw new Error('CI evidence storage is unavailable or its memory limit was exceeded.'); |
| 84 | + } |
| 85 | + this._prune(); |
| 86 | + if (!this.canAdd(retainedIds, log.text.length)) { |
| 87 | + return undefined; |
| 88 | + } |
| 89 | + while (this._entries.size >= maximumEvidenceEntries || this._characters + log.text.length > maximumEvidenceCharacters) { |
| 90 | + this._delete([...this._entries.keys()].find(id => !retainedIds.has(id))!); |
| 91 | + } |
| 92 | + const entry: AgentMergeCIEvidence = { |
| 93 | + id: generateUuid(), scope, runAttempt, |
| 94 | + job: { id: job.id, runId: job.runId, name: job.name.slice(0, 200), headSha: job.headSha, runAttempt: job.runAttempt, checkRunId: job.checkRunId }, |
| 95 | + log: { ...log }, |
| 96 | + ...indexLines(log.text), |
| 97 | + expiresAt: Date.now() + evidenceLifetime, |
| 98 | + }; |
| 99 | + this._entries.set(entry.id, entry); |
| 100 | + this._characters += log.text.length; |
| 101 | + this._listeners.set(entry.id, Event.once(Event.fromDOMEventEmitter(signal, 'abort'))(() => this._delete(entry.id))); |
| 102 | + this._expiry.schedule(); |
| 103 | + return entry; |
| 104 | + } |
| 105 | + |
| 106 | + continue(scope: string, continuation: AgentMergeCIContinuation): { cursor: string } { |
| 107 | + const cursor = generateUuid(); |
| 108 | + this._cursors.set(cursor, { ...continuation, scope, expiresAt: Date.now() + evidenceLifetime }); |
| 109 | + this._expiry.schedule(); |
| 110 | + return { cursor }; |
| 111 | + } |
| 112 | + |
| 113 | + resolve(cursor: string, scope: string): AgentMergeCIContinuation { |
| 114 | + this._prune(); |
| 115 | + const continuation = this._cursors.get(cursor); |
| 116 | + if (!continuation || continuation.scope !== scope) { |
| 117 | + throw new Error('Invalid, expired, or unauthorized CI cursor. Read a new summary for the active Agent Merge turn.'); |
| 118 | + } |
| 119 | + return continuation; |
| 120 | + } |
| 121 | + |
| 122 | + private _delete(id: string): void { |
| 123 | + const entry = this._entries.get(id); |
| 124 | + if (entry) { |
| 125 | + this._characters -= entry.log.text.length; |
| 126 | + this._entries.delete(id); |
| 127 | + this._listeners.deleteAndDispose(id); |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + private _prune(): void { |
| 132 | + for (const entry of this._entries.values()) { |
| 133 | + if (entry.expiresAt <= Date.now()) { |
| 134 | + this._delete(entry.id); |
| 135 | + } |
| 136 | + } |
| 137 | + for (const [id, cursor] of [...this._cursors]) { |
| 138 | + if (cursor.expiresAt <= Date.now()) { |
| 139 | + this._cursors.delete(id); |
| 140 | + } |
| 141 | + } |
| 142 | + if (this._entries.size || this._cursors.size) { |
| 143 | + this._expiry.schedule(); |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + override dispose(): void { |
| 148 | + this._entries.clear(); |
| 149 | + this._cursors.clear(); |
| 150 | + this._characters = 0; |
| 151 | + super.dispose(); |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +interface CILine { |
| 156 | + readonly line: number; |
| 157 | + readonly column: number; |
| 158 | + readonly text: string; |
| 159 | +} |
| 160 | + |
| 161 | +export interface CIExcerpt { |
| 162 | + readonly lines: readonly CILine[]; |
| 163 | + readonly next?: AgentMergeCIRequest; |
| 164 | + readonly previous?: AgentMergeCIRequest; |
| 165 | +} |
| 166 | + |
| 167 | +export function ciJsonBytes(value: object): number { |
| 168 | + return VSBuffer.fromString(JSON.stringify(value)).byteLength; |
| 169 | +} |
| 170 | + |
| 171 | +function indexLines(text: string): Pick<AgentMergeCIEvidence, 'lineCount' | 'lineStartOffsets'> { |
| 172 | + const offsets: number[] = []; |
| 173 | + let lineCount = 0; |
| 174 | + for (let offset = 0; offset < text.length; lineCount++) { |
| 175 | + if (lineCount % lineIndexStride === 0) { |
| 176 | + offsets.push(offset); |
| 177 | + } |
| 178 | + const end = text.indexOf('\n', offset); |
| 179 | + offset = end < 0 ? text.length : end + 1; |
| 180 | + } |
| 181 | + return { lineCount, lineStartOffsets: Uint32Array.from(offsets) }; |
| 182 | +} |
| 183 | + |
| 184 | +function* linesInRange(entry: AgentMergeCIEvidence, first: number, last: number): Iterable<{ line: number; start: number; end: number }> { |
| 185 | + const text = entry.log.text; |
| 186 | + const checkpoint = Math.floor((first - 1) / lineIndexStride); |
| 187 | + let line = checkpoint * lineIndexStride + 1; |
| 188 | + for (let start = entry.lineStartOffsets[checkpoint] ?? text.length; start < text.length && line <= last; line++) { |
| 189 | + const newline = text.indexOf('\n', start); |
| 190 | + const end = newline < 0 ? text.length : newline; |
| 191 | + if (line >= first) { |
| 192 | + yield { line, start, end: end > start && text[end - 1] === '\r' ? end - 1 : end }; |
| 193 | + } |
| 194 | + start = end + 1; |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +export function readCIRange(entry: AgentMergeCIEvidence, request: AgentMergeCIRequest, budget = excerptBytes): CIExcerpt { |
| 199 | + const first = request.startLine ?? 1; |
| 200 | + const last = Math.min(request.endLine ?? first + 199, entry.lineCount); |
| 201 | + if (first > Math.max(1, entry.lineCount)) { |
| 202 | + throw new Error('The requested line is outside the captured CI evidence. The total line count is unknown when complete is false.'); |
| 203 | + } |
| 204 | + const result: CILine[] = []; |
| 205 | + let remaining = budget; |
| 206 | + for (const line of linesInRange(entry, first, last)) { |
| 207 | + let column = line.line === first ? request.startColumn ?? 1 : 1; |
| 208 | + if (column > Math.max(1, line.end - line.start)) { |
| 209 | + throw new Error('The requested column is outside the captured CI line.'); |
| 210 | + } |
| 211 | + do { |
| 212 | + const text = entry.log.text.slice(line.start + column - 1, Math.min(line.end, line.start + column - 1 + 500)); |
| 213 | + const part = { line: line.line, column, text }; |
| 214 | + const bytes = ciJsonBytes(part) + 1; |
| 215 | + if (bytes > remaining || result.length >= 200) { |
| 216 | + return { lines: result, next: { mode: 'range', evidenceId: entry.id, startLine: line.line, startColumn: column, endLine: last } }; |
| 217 | + } |
| 218 | + result.push(part); |
| 219 | + remaining -= bytes; |
| 220 | + column += text.length; |
| 221 | + } while (line.start + column - 1 < line.end); |
| 222 | + } |
| 223 | + return { lines: result }; |
| 224 | +} |
| 225 | + |
| 226 | +export function readCITail(entry: AgentMergeCIEvidence, lineCount = 100, budget = excerptBytes): CIExcerpt { |
| 227 | + const candidates = [...linesInRange(entry, Math.max(1, entry.lineCount - lineCount + 1), entry.lineCount)]; |
| 228 | + const result: CILine[] = []; |
| 229 | + let remaining = budget; |
| 230 | + for (const line of candidates.reverse()) { |
| 231 | + let end = line.end; |
| 232 | + do { |
| 233 | + const start = Math.max(line.start, end - 200); |
| 234 | + const part = { line: line.line, column: start - line.start + 1, text: entry.log.text.slice(start, end) }; |
| 235 | + const bytes = ciJsonBytes(part) + 1; |
| 236 | + if (bytes > remaining || result.length >= 200) { |
| 237 | + return { |
| 238 | + lines: result.reverse(), |
| 239 | + previous: { mode: 'range', evidenceId: entry.id, startLine: Math.max(1, line.line - 199), endLine: line.line }, |
| 240 | + }; |
| 241 | + } |
| 242 | + result.push(part); |
| 243 | + remaining -= bytes; |
| 244 | + end = start; |
| 245 | + } while (end > line.start); |
| 246 | + } |
| 247 | + const first = result.at(-1)?.line ?? 1; |
| 248 | + return { |
| 249 | + lines: result.reverse(), |
| 250 | + previous: first > 1 ? { mode: 'range', evidenceId: entry.id, startLine: Math.max(1, first - 200), endLine: first - 1 } : undefined, |
| 251 | + }; |
| 252 | +} |
| 253 | + |
| 254 | +export function searchCIEvidence(entry: AgentMergeCIEvidence, request: AgentMergeCIRequest) { |
| 255 | + const query = new RegExp(escapeRegExpCharacters(request.query!), 'i'); |
| 256 | + const first = request.startLine ?? 1; |
| 257 | + if (first > Math.max(1, entry.lineCount)) { |
| 258 | + throw new Error('The search start line is outside the captured CI evidence.'); |
| 259 | + } |
| 260 | + const context = request.contextLines ?? 2; |
| 261 | + const matches: { line: number; excerpt: readonly CILine[]; read: AgentMergeCIRequest }[] = []; |
| 262 | + let bytes = 0; |
| 263 | + let scannedThrough = first - 1; |
| 264 | + for (const line of linesInRange(entry, first, Math.min(entry.lineCount, first + 49_999))) { |
| 265 | + if (matches.length >= 5) { |
| 266 | + break; |
| 267 | + } |
| 268 | + const text = entry.log.text.slice(line.start, line.end); |
| 269 | + const index = text.search(query); |
| 270 | + if (index >= 0) { |
| 271 | + const excerpt: CILine[] = []; |
| 272 | + for (const surrounding of linesInRange(entry, Math.max(1, line.line - context), Math.min(entry.lineCount, line.line + context))) { |
| 273 | + const column = surrounding.line === line.line ? index + 1 : 1; |
| 274 | + const length = surrounding.line === line.line ? 200 : 60; |
| 275 | + excerpt.push({ line: surrounding.line, column, text: entry.log.text.slice(surrounding.start + column - 1, Math.min(surrounding.end, surrounding.start + column - 1 + length)) }); |
| 276 | + } |
| 277 | + const match = { |
| 278 | + line: line.line, excerpt, |
| 279 | + read: { mode: 'range' as const, evidenceId: entry.id, startLine: line.line, startColumn: index + 1, endLine: line.line }, |
| 280 | + }; |
| 281 | + const size = ciJsonBytes(match); |
| 282 | + if (bytes + size > 8_000 && matches.length) { |
| 283 | + break; |
| 284 | + } |
| 285 | + matches.push(match); |
| 286 | + bytes += size; |
| 287 | + } |
| 288 | + scannedThrough = line.line; |
| 289 | + } |
| 290 | + return { |
| 291 | + matches, |
| 292 | + scannedThrough, |
| 293 | + next: scannedThrough < entry.lineCount ? { ...request, startLine: scannedThrough + 1 } : undefined, |
| 294 | + }; |
| 295 | +} |
| 296 | + |
| 297 | +export function ciFailureExcerpt(entry: AgentMergeCIEvidence): readonly CILine[] { |
| 298 | + const result: CILine[] = []; |
| 299 | + for (const line of linesInRange(entry, 1, entry.lineCount)) { |
| 300 | + const text = entry.log.text.slice(line.start, line.end); |
| 301 | + const index = text.search(/\b(?:[1-9]\d* failing|failed|AssertionError|Error|FAIL(?:URE|ED)?)\b|##\[error\]/i); |
| 302 | + if (index >= 0) { |
| 303 | + const column = Math.max(1, index - 40 + 1); |
| 304 | + result.push({ line: line.line, column, text: text.slice(column - 1, column - 1 + 200) }); |
| 305 | + if (result.length > 3) { |
| 306 | + result.shift(); |
| 307 | + } |
| 308 | + } |
| 309 | + } |
| 310 | + return result; |
| 311 | +} |
| 312 | + |
| 313 | +export function ciEvidenceMetadata(entry: AgentMergeCIEvidence) { |
| 314 | + return { |
| 315 | + evidenceId: entry.id, |
| 316 | + runId: entry.job.runId, |
| 317 | + runAttempt: entry.runAttempt, |
| 318 | + jobId: entry.job.id, |
| 319 | + jobHeadSha: entry.job.headSha ?? null, |
| 320 | + jobRunAttempt: entry.job.runAttempt ?? null, |
| 321 | + complete: !entry.log.truncated, |
| 322 | + capturedLines: entry.lineCount, |
| 323 | + lineNumbering: 'One-based lines and UTF-16 columns in the cached redacted text.', |
| 324 | + totalLines: entry.log.truncated ? null : entry.lineCount, |
| 325 | + bytesRead: entry.log.bytesRead ?? null, |
| 326 | + maximumBytes: entry.log.maximumBytes ?? null, |
| 327 | + terminalLimit: entry.log.truncated ? 'Download stopped before EOF. Only the captured prefix is available; the true tail and uncaptured lines are unavailable. Repeating this read cannot extend the download limit.' : null, |
| 328 | + expiresAt: new Date(entry.expiresAt).toISOString(), |
| 329 | + }; |
| 330 | +} |
0 commit comments