diff --git a/docs/code/sentry-integration.md b/docs/code/sentry-integration.md new file mode 100644 index 0000000..bff9139 --- /dev/null +++ b/docs/code/sentry-integration.md @@ -0,0 +1,97 @@ +--- +title: "Sentry Auto-fixes" +sidebarLabel: "Sentry Auto-fixes" +description: "Turn Sentry error groups into repo-routed fixes from the workspace worker" +section: "Automation" +order: 4 +dateModified: 2026-09-04 +--- + +# Sentry Auto-fixes + +The workspace worker can poll one or more Sentry projects for unresolved error +groups and run actionable errors through the normal fix pipeline: isolated +worktree, coding agent, tests, commit, and pull request. + +## Configure projects in `workspace.toml` + +Add one `[[error_monitors]]` entry per Sentry project. Every entry maps to the +repository that owns the code, so a multi-repo worker never has to guess where +an error should be fixed. + +```toml +[[error_monitors]] +id = "api-production" +provider = "sentry" +repo = "backend" +team = "platform" # optional; must match a [[teams]] name +organization = "acme" +project = "api" +query = "environment:production level:error" +poll_interval = 60 +min_occurrences = 5 +max_per_tick = 3 +env_file = "env/sentry-api.env" + +[[error_monitors]] +id = "web-production" +provider = "sentry" +repo = "frontend" +organization = "acme" +project = "web" +env_file = "env/sentry-web.env" +``` + +`repo` may be omitted only when the workspace has exactly one `[[repos]]` +entry. In a multi-repo workspace it is required. `team` is optional and lets +the fix run inherit that team's environment and identity in addition to the +repository environment. + +Each source is independent: use a separate `env_file` or +`[error_monitors.env]` table when projects need different credentials. + +```bash +# env/sentry-api.env +SENTRY_AUTH_TOKEN=sntrys_... +``` + +```toml +[[error_monitors]] +id = "internal-api" +provider = "sentry" +repo = "backend" +organization = "acme" +project = "api" +base_url = "https://sentry.internal.example" + [error_monitors.env] + SENTRY_AUTH_TOKEN = "sntrys_..." +``` + +Do not put a Sentry DSN here. A DSN sends events into Sentry; polling issues +requires an auth token plus the organization and project slugs. Create an auth +token with `project:read` and `event:read` access. + +Credential precedence, from lowest to highest, is: process environment, +workspace `.env`, repo `env_file`, `[repos.env]`, team credentials, the error +monitor's `env_file`, then `[error_monitors.env]`. This allows one worker to +serve teams and projects whose tokens differ. + +## Behavior + +An error is eligible when it meets `min_occurrences` (default `5`) and includes +a title plus a culprit, exception type, or filename. At most `max_per_tick` +(default `3`) errors are dispatched per poll. `poll_interval` defaults to +`[defaults].poll_interval`. + +Handled issue IDs are stored in the workspace database under a source key that +includes the provider and configured source `id`. That prevents collisions +between Sentry projects. A failed fix is not automatically repeated; a run +deferred because the repo or agent capacity is busy is released and retried on +a later poll. + +The provider contract is shared by all error monitors. Sentry is the first +adapter; adding Datadog support does not require another polling, deduplication, +or workspace-routing implementation. + +`[[error_monitors]]` changes are validated by live reload but require a worker +restart because clients and credentials are startup-scoped. diff --git a/docs/code/worker.md b/docs/code/worker.md index 40aa7f6..9469fec 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -42,6 +42,14 @@ devintern webhook serve Set `AGENT_HARNESS=codex,grok` (comma-separated, priority first) in the workspace `.env` so the worker keeps going when one agent hits a usage limit. Failover applies to every worker job: tracker tasks, PR review addressing, `@mention` runs, conflict resolution, scheduled automations, estimations, dashboard retries, and relay-driven work. Details: [Failover across multiple harnesses](./configuration.md#failover-across-multiple-harnesses). +## Error-monitor auto-fixes + +`[[error_monitors]]` entries let the worker turn unresolved production errors +into normal repo-scoped fix runs. Each Sentry project maps explicitly to one +`[[repos]]` entry and can inherit an optional `[[teams]]` environment, so one +worker can safely serve multiple teams, repositories, and credentials. See +[Sentry Auto-fixes](./sentry-integration.md) for the schema and setup. + ## Recurring automations Put recurring work in `workspace.toml`. Set `repo` when the workspace has multiple repositories; it is optional for a one-repo workspace: diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 0a30697..a0707b4 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -94,6 +94,7 @@ prompt = "Review the frontend and clean up one source of recurring noise." - `[worker.schedule]` gates only new-task pickup: multiple windows union, windows may cross midnight, `blocked` wins on overlap, and a missed whole window triggers one catch-up drain at startup. Timezone/DST semantics and `devintern worker run-now` are covered in [Running the Worker Unattended: Working windows](./automated-task-processing.md#working-windows-quiet-hours). - `[[automations]]` uses the same schema as single-repo `.devintern-code/automations.toml`. An entry must name `repo` when the workspace has more than one repository. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics. - `[[estimations]]` schedules unattended story-point sweeps (tracker query + cron/interval, no `prompt`, no `repo`). The workspace tracker must support estimation. See [Worker Daemon → Scheduled story-point estimation](./worker.md#scheduled-story-point-estimation). +- `[[error_monitors]]` maps each Sentry project to one repo and an optional team, with per-source credential layers for multi-project setups. See [Sentry Auto-fixes](./sentry-integration.md). ### Multiple teams and tracker boards @@ -229,7 +230,7 @@ devintern worker # auto-detects ~/.devintern/workspace.toml devintern worker --workspace /path/to/workspace.toml ``` -The single-source fleet query comes from `[defaults].task_query`; multi-team workspaces use each team's `task_query`. A workspace with automations or estimations can omit the defaults query and run as a schedules-only worker. Poll interval, per-task flags, and the embedded dashboard are also set in `workspace.toml` (`poll_interval`, `worker_task_args`, `[worker.schedule]` quiet hours, `[workspace].dashboard` / `dashboard_port`). Direct webhooks are an advanced repo-local service: run `devintern webhook serve` from that repository as a separate process. Automation and estimation schedule state and leases, plus the task-polling timestamp used for missed-window catch-up, live in the central workspace database. +The single-source fleet query comes from `[defaults].task_query`; multi-team workspaces use each team's `task_query`. A workspace with automations, estimations, or an enabled error monitor can omit the defaults query. Poll interval, per-task flags, and the embedded dashboard are also set in `workspace.toml` (`poll_interval`, `worker_task_args`, `[worker.schedule]` quiet hours, `[workspace].dashboard` / `dashboard_port`). Direct webhooks are an advanced repo-local service: run `devintern webhook serve` from that repository as a separate process. Automation and estimation schedule state and leases, plus task-polling and error-monitor deduplication state, live in the central workspace database. While the daemon is running you can request one immediate drain (for example while quiet hours are closed) with `devintern worker run-now`; see [Working windows](./automated-task-processing.md#working-windows-quiet-hours). @@ -239,6 +240,7 @@ The worker watches `workspace.toml` and reloads it automatically a moment after - **Routing rules, repos, defaults/team `task_query`, team `repo`, `[[automations]]`, `[[estimations]]`, `worker_task_args`, `poll_interval`, `worktrees_ttl_days`, and conflict-resolution mode/schedules apply to subsequent work.** Runs already in progress finish under the configuration they started with; everything picked up afterwards uses the new one. Changing a repo's `remote` updates its managed bare clone the next time that repo is prepared. - **Team identity and credentials are startup-only.** Restart after changing a team's name, tracker, `env_file`, or inline `[teams.env]` values. +- **Error monitor clients are startup-only.** Restart after changing `[[error_monitors]]`, including project routing or source credentials. - **A broken edit never takes the daemon down.** The reload validates the file first; parse or schema errors are logged (naming the offending entries) and the last valid configuration keeps serving until you fix it. Rewriting identical content is ignored. - **Manual fallback:** send SIGHUP (`kill -HUP `) to force an immediate reload if file watching is unavailable on your system. - **Startup-only settings** still require a restart: tracker credentials in the workspace `.env` and `[defaults].tracker` (the tracker client and its detector are built once), `[worker.schedule]` quiet hours (the working-window gate is built once at startup), plus `[workspace].dashboard` / `dashboard_port`. A reload that changes one of these settings is rejected in full, so the active config remains internally consistent. diff --git a/packages/code/src/lib/error-monitor.ts b/packages/code/src/lib/error-monitor.ts new file mode 100644 index 0000000..27e35b0 --- /dev/null +++ b/packages/code/src/lib/error-monitor.ts @@ -0,0 +1,161 @@ +/** + * Provider-neutral error-monitor polling. + * + * Provider adapters normalize their native issue shape through + * {@link ErrorMonitorProvider}. The acquirer owns lifecycle, deduplication, + * thresholds, and dispatch so future providers (for example Datadog) do not + * need to duplicate worker behavior. + */ + +import type { TaskExecutionResult } from "./task-polling-acquirer"; +import type { WebhookQueue } from "./webhook-queue"; +import type { Acquirer } from "../worker"; +import type { ErrorMonitorConfig } from "./workspace/config"; +import { SentryClient } from "./sentry-client"; + +/** Minimum normalized issue data needed by the shared acquirer. */ +export interface ErrorMonitorIssue { + /** Provider-stable identifier used for durable deduplication. */ + externalId: string; + /** Human-readable identifier used in logs and task filenames. */ + displayId: string; + title: string; + occurrenceCount: number; +} + +export interface IssueValidity { + valid: boolean; + reason?: string; +} + +/** Adapter contract implemented once per error-monitoring vendor. */ +export interface ErrorMonitorProvider { + readonly providerName: string; + fetchIssues(): Promise; + validateIssue(issue: TIssue): IssueValidity; + buildTaskMarkdown(issue: TIssue): string; +} + +export interface ErrorMonitorAcquirerOptions { + sourceId: string; + intervalSeconds: number; + queue: WebhookQueue; + provider: ErrorMonitorProvider; + executeTask: (issue: TIssue, markdown: string) => Promise; + minOccurrences?: number; + maxIssuesPerTick?: number; + verbose?: boolean; +} + +/** Build the configured vendor adapter without leaking vendor logic into the worker. */ +export function createErrorMonitorProvider( + config: ErrorMonitorConfig, + env: Record, +): ErrorMonitorProvider { + switch (config.provider) { + case "sentry": { + const authToken = env.SENTRY_AUTH_TOKEN; + if (!authToken) { + throw new Error( + `Error monitor "${config.id}" is missing SENTRY_AUTH_TOKEN. ` + + "Add it to the workspace, repo, team, or source env_file/env layer.", + ); + } + return new SentryClient({ + authToken, + organization: config.organization, + project: config.project, + baseUrl: config.baseUrl, + query: config.query, + }); + } + } +} + +/** Shared polling acquirer for Sentry, Datadog, and future adapters. */ +export class ErrorMonitorAcquirer implements Acquirer { + readonly name: string; + private readonly options: ErrorMonitorAcquirerOptions & { + minOccurrences: number; + maxIssuesPerTick: number; + }; + private timer: ReturnType | null = null; + private busy = false; + + constructor(options: ErrorMonitorAcquirerOptions) { + this.options = { minOccurrences: 5, maxIssuesPerTick: 3, ...options }; + this.name = `errors:${options.provider.providerName}:${options.sourceId}`; + } + + async start(): Promise { + if (this.timer) return; + console.log( + `šŸ”Ž [${this.name}] polling every ${this.options.intervalSeconds}s ` + + `(min occurrences: ${this.options.minOccurrences}, max per tick: ${this.options.maxIssuesPerTick})`, + ); + await this.tick(); + this.timer = setInterval(() => void this.tick(), this.options.intervalSeconds * 1000); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async tick(): Promise { + if (this.busy) return; + this.busy = true; + + const { queue, provider, minOccurrences, maxIssuesPerTick, verbose } = this.options; + const dedupeSource = this.name; + try { + const issues = await provider.fetchIssues(); + let handled = 0; + for (const issue of issues) { + if (handled >= maxIssuesPerTick) break; + + if (!Number.isFinite(issue.occurrenceCount) || issue.occurrenceCount < minOccurrences) { + if (verbose) { + console.log( + ` [${this.name}] skipping ${issue.displayId}: only ${issue.occurrenceCount} occurrence(s); need ${minOccurrences}`, + ); + } + continue; + } + const validity = provider.validateIssue(issue); + if (!validity.valid) { + if (verbose) { + console.log( + ` [${this.name}] skipping ${issue.displayId}: ${validity.reason ?? "not actionable"}`, + ); + } + continue; + } + + // Claim atomically before execution so competing watcher processes + // cannot both submit the same issue. A capacity deferral is explicitly + // unclaimed so it can run on a later tick. + if (!queue.tryMarkProcessed(dedupeSource, issue.externalId)) continue; + console.log(`\nšŸ“Œ [${this.name}] ${issue.displayId}: ${issue.title}`); + const result = await this.options.executeTask(issue, provider.buildTaskMarkdown(issue)); + if (result === "deferred") { + queue.unmarkProcessed(dedupeSource, issue.externalId); + console.log(`ā³ [${this.name}] ${issue.displayId} deferred; it will be retried`); + break; + } + handled++; + console.log( + result + ? `āœ… [${this.name}] fix for ${issue.displayId} completed` + : `āš ļø [${this.name}] fix for ${issue.displayId} did not complete cleanly`, + ); + } + } catch (error) { + console.warn(`āš ļø [${this.name}] polling tick failed: ${(error as Error).message}`); + } finally { + this.busy = false; + } + } +} diff --git a/packages/code/src/lib/sentry-client.ts b/packages/code/src/lib/sentry-client.ts new file mode 100644 index 0000000..83e57f2 --- /dev/null +++ b/packages/code/src/lib/sentry-client.ts @@ -0,0 +1,162 @@ +/** + * Minimal Sentry API client for the error-watching acquirer. + * + * Reads unresolved issues from either the project-scoped endpoint (when a + * project slug is configured) or the organization-wide endpoint. Works with + * sentry.io and self-hosted Sentry (SENTRY_BASE_URL). + */ + +import type { ErrorMonitorIssue, ErrorMonitorProvider, IssueValidity } from "./error-monitor"; + +export const DEFAULT_SENTRY_BASE_URL = "https://sentry.io"; + +/** Subset of the Sentry issue (group) payload the acquirer needs. */ +export interface SentryIssue extends ErrorMonitorIssue { + id: string; + shortId: string; + title: string; + culprit: string | null; + level: string | null; + status: string; + /** Total event count as reported by Sentry (string in the API). */ + count: string; + firstSeen: string; + lastSeen: string; + permalink: string; + metadata?: { + type?: string; + value?: string; + filename?: string; + function?: string; + }; +} + +export interface SentryClientOptions { + authToken: string; + organization: string; + /** Project slug; when set, queries are scoped to this project. */ + project?: string; + baseUrl?: string; + query?: string; + fetchImpl?: typeof fetch; +} + +export class SentryClient implements ErrorMonitorProvider { + readonly providerName = "sentry"; + private readonly authToken: string; + readonly organization: string; + readonly project?: string; + private readonly baseUrl: string; + private readonly query?: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: SentryClientOptions) { + this.authToken = options.authToken; + this.organization = options.organization; + this.project = options.project; + this.baseUrl = (options.baseUrl || DEFAULT_SENTRY_BASE_URL).replace(/\/+$/, ""); + this.query = options.query; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + /** + * Fetch unresolved issues, newest activity first. + * + * @param query - Extra Sentry search query terms (ANDed with `is:unresolved`) + * @returns Parsed issue list + */ + async fetchUnresolvedIssues(query = this.query): Promise { + const params = new URLSearchParams({ + query: query ? `is:unresolved ${query}`.trim() : "is:unresolved", + sort: "date", + statsPeriod: "14d", + per_page: "100", + }); + const path = this.project + ? `/api/0/projects/${encodeURIComponent(this.organization)}/${encodeURIComponent(this.project)}/issues/` + : `/api/0/organizations/${encodeURIComponent(this.organization)}/issues/`; + const url = `${this.baseUrl}${path}?${params.toString()}`; + + const response = await this.fetchImpl(url, { + headers: { + Authorization: `Bearer ${this.authToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.status === 401 || response.status === 403) { + throw new Error(`Sentry rejected the auth token (HTTP ${response.status})`); + } + if (!response.ok) { + throw new Error(`Sentry API error (HTTP ${response.status}) for ${url}`); + } + + const body = (await response.json()) as Array>; + return body.map((raw) => { + const id = String(raw.id); + const shortId = String(raw.shortId ?? raw.id); + const count = String(raw.count ?? "0"); + return { + externalId: `issue:${id}`, + displayId: shortId, + occurrenceCount: Number(count), + id, + shortId, + title: String(raw.title ?? ""), + culprit: typeof raw.culprit === "string" ? raw.culprit : null, + level: typeof raw.level === "string" ? raw.level : null, + status: String(raw.status ?? ""), + count, + firstSeen: String(raw.firstSeen ?? ""), + lastSeen: String(raw.lastSeen ?? ""), + permalink: String(raw.permalink ?? ""), + metadata: (raw.metadata as SentryIssue["metadata"]) ?? undefined, + }; + }); + } + + /** Shared-provider adapter entry point. */ + fetchIssues(): Promise { + return this.fetchUnresolvedIssues(); + } + + /** Sentry-specific actionability check after the shared occurrence gate. */ + validateIssue(issue: SentryIssue): IssueValidity { + if (!issue.title.trim()) return { valid: false, reason: "missing error title" }; + if (!issue.culprit && !issue.metadata?.type && !issue.metadata?.filename) { + return { valid: false, reason: "no culprit or exception metadata to locate the error" }; + } + return { valid: true }; + } + + /** Render a Sentry issue as a local markdown task for the normal pipeline. */ + buildTaskMarkdown(issue: SentryIssue): string { + const lines = [ + `# Fix Sentry error ${issue.shortId}`, + "", + "## Error", + "", + `- **Title**: ${issue.title}`, + `- **Sentry ID**: ${issue.shortId} (${issue.id})`, + `- **Level**: ${issue.level ?? "error"}`, + `- **Events**: ${issue.count}`, + `- **First seen**: ${issue.firstSeen}`, + `- **Last seen**: ${issue.lastSeen}`, + `- **Link**: ${issue.permalink}`, + ]; + if (issue.culprit) lines.push(`- **Culprit**: ${issue.culprit}`); + if (issue.metadata?.type) lines.push(`- **Exception type**: ${issue.metadata.type}`); + if (issue.metadata?.value) lines.push(`- **Exception message**: ${issue.metadata.value}`); + if (issue.metadata?.filename) lines.push(`- **File**: ${issue.metadata.filename}`); + lines.push( + "", + "## Task", + "", + "Reproduce the root cause of this error from the details above, implement the", + "minimal fix in this repository, and add or adjust tests covering the failure", + "path. Do not change unrelated behavior.", + "", + ); + return lines.join("\n"); + } +} diff --git a/packages/code/src/lib/webhook-queue.ts b/packages/code/src/lib/webhook-queue.ts index 1dd50b7..407093b 100644 --- a/packages/code/src/lib/webhook-queue.ts +++ b/packages/code/src/lib/webhook-queue.ts @@ -395,6 +395,24 @@ export class WebhookQueue { ); } + /** + * Atomically claim a provider-issued event id for processing. + * + * Unlike a separate {@link hasProcessed} / {@link markProcessed} pair, this + * is safe when multiple queue connections race for the same event. Only the + * connection that inserts the row owns the claim and may dispatch work. + * + * @returns `true` when this call acquired the claim + */ + tryMarkProcessed(source: string, externalId: string): boolean { + const result = this.db.run( + `INSERT INTO processed_events (source, external_id, processed_at) VALUES (?, ?, ?) + ON CONFLICT(source, external_id) DO NOTHING`, + [source, externalId, Date.now()], + ); + return result.changes === 1; + } + /** Release a provisional processed marker when work was deferred before execution. */ unmarkProcessed(source: string, externalId: string): void { this.db.run(`DELETE FROM processed_events WHERE source = ? AND external_id = ?`, [ diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index b9e5e08..3cf8cb7 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -95,6 +95,36 @@ export interface RepoConfig { env: Record; } +/** Provider-neutral base configuration for one error-monitoring project. */ +export interface ErrorMonitorConfigBase { + /** Stable source name; namespaces dedupe and worker logs. */ + id: string; + /** Adapter discriminator. */ + provider: string; + enabled: boolean; + /** Repository where fixes for this monitoring project are implemented. */ + repo: string; + /** Optional owning team, used for credentials and task execution context. */ + team?: string; + query?: string; + intervalSeconds: number; + minOccurrences: number; + maxIssuesPerTick: number; + envFile?: string; + env: Record; +} + +/** Sentry-specific source settings layered on the provider-neutral base. */ +export interface SentryErrorMonitorConfig extends ErrorMonitorConfigBase { + provider: "sentry"; + organization: string; + project: string; + baseUrl?: string; +} + +/** Discriminated union extended by each supported monitoring adapter. */ +export type ErrorMonitorConfig = SentryErrorMonitorConfig; + /** One routing rule from a `[[routing.rules]]` entry. Set criteria are AND-ed; list values match any-of. */ export interface RoutingRule { /** Name of the repo tasks matching this rule route to. */ @@ -126,6 +156,8 @@ export interface WorkspaceConfig { /** Team tracker sources; empty means the single `[defaults]` fleet query. */ teams: TeamConfig[]; repos: RepoConfig[]; + /** Error-monitoring projects, each explicitly mapped to a repo. */ + errorMonitors: ErrorMonitorConfig[]; routing: RoutingRule[]; automations: AutomationConfig[]; estimations: EstimationConfig[]; @@ -338,6 +370,7 @@ export function parseWorkspaceConfig( const defaultsTable = asTable(document.defaults, "[defaults]", errors); const tracker = readString(defaultsTable, "tracker", "[defaults]", errors); + const errorMonitorTables = asTableArray(document.error_monitors, "[[error_monitors]]", errors); const teams: TeamConfig[] = []; const teamNames = new Set(); @@ -375,7 +408,7 @@ export function parseWorkspaceConfig( // Single-defaults mode requires a fleet tracker; with [[teams]] every team // brings its own (a [defaults].tracker alongside teams is still honored // for any team that omits one). - if (!tracker && teams.length === 0) { + if (!tracker && teams.length === 0 && errorMonitorTables.length === 0) { errors.push('[defaults].tracker is required (e.g. tracker = "jira").'); } if (tracker && !supportsPolling(tracker)) { @@ -468,6 +501,71 @@ export function parseWorkspaceConfig( } } + const errorMonitors: ErrorMonitorConfig[] = []; + const errorMonitorIds = new Set(); + for (const [index, table] of errorMonitorTables.entries()) { + const label = `[[error_monitors]][${index}]`; + const id = readString(table, "id", label, errors); + const provider = readString(table, "provider", label, errors); + const requestedRepo = readString(table, "repo", label, errors); + const repo = requestedRepo ?? (repos.length === 1 ? repos[0]?.name : undefined); + const team = readString(table, "team", label, errors); + const organization = readString(table, "organization", label, errors); + const project = readString(table, "project", label, errors); + + if (!id) { + errors.push(`${label}.id is required.`); + } else if (!REPO_NAME_PATTERN.test(id)) { + errors.push( + `${label}.id "${id}" must contain only letters, digits, ".", "_" or "-" and not start with a separator.`, + ); + } else if (errorMonitorIds.has(id.toLowerCase())) { + errors.push(`Duplicate error monitor id "${id}". IDs must be unique.`); + } else { + errorMonitorIds.add(id.toLowerCase()); + } + if (provider !== "sentry") errors.push(`${label}.provider must be "sentry".`); + if (!repo) { + errors.push(`${label}.repo is required in a workspace with multiple repositories.`); + } else if (!repoNames.has(repo)) { + errors.push(`${label}.repo "${repo}" does not match any [[repos]] name.`); + } + if (team && !teamNames.has(team.toLowerCase())) { + errors.push(`${label}.team "${team}" does not match any [[teams]] name.`); + } + if (!organization) errors.push(`${label}.organization is required for Sentry.`); + if (!project) errors.push(`${label}.project is required for Sentry.`); + + errorMonitors.push({ + id: id ?? "", + provider: "sentry", + enabled: readOptionalBoolean(table, "enabled", label, errors) ?? true, + repo: repo ?? "", + team, + organization: organization ?? "", + project: project ?? "", + baseUrl: readString(table, "base_url", label, errors), + query: readString(table, "query", label, errors), + intervalSeconds: + readOptionalInteger(table, "poll_interval", label, errors, { + min: 1, + message: `${label}.poll_interval must be a positive integer (seconds).`, + }) ?? defaults.pollIntervalSeconds, + minOccurrences: + readOptionalInteger(table, "min_occurrences", label, errors, { + min: 1, + message: `${label}.min_occurrences must be a positive integer.`, + }) ?? 5, + maxIssuesPerTick: + readOptionalInteger(table, "max_per_tick", label, errors, { + min: 1, + message: `${label}.max_per_tick must be a positive integer.`, + }) ?? 3, + envFile: readString(table, "env_file", label, errors), + env: readEnvTable(table, label, errors), + }); + } + const routingTable = asTable(document.routing, "[routing]", errors); const routing: RoutingRule[] = []; for (const [index, table] of asTableArray( @@ -562,6 +660,7 @@ export function parseWorkspaceConfig( defaults, teams, repos, + errorMonitors, routing, automations: automationResult.automations, estimations: estimationResult.estimations, diff --git a/packages/code/src/lib/workspace/env.ts b/packages/code/src/lib/workspace/env.ts index 350b1e2..7c2709a 100644 --- a/packages/code/src/lib/workspace/env.ts +++ b/packages/code/src/lib/workspace/env.ts @@ -12,7 +12,7 @@ import { existsSync, readFileSync } from "fs"; import { isAbsolute, join } from "path"; -import type { RepoConfig, TeamConfig } from "./config"; +import type { ErrorMonitorConfig, RepoConfig, TeamConfig } from "./config"; import { resolveWorkspaceDir, workspaceDbPath, workspaceEnvPath } from "./paths"; import { ANALYTICS_CONFIG_DIR_ENV } from "../analytics"; @@ -168,3 +168,23 @@ export function buildTeamTaskEnv( env[WORKSPACE_TEAM_ENV] = team.name; return env; } + +/** + * Compose credentials and task context for one error-monitor source. + * + * Precedence: process < workspace < repo < team < source env_file < source + * inline env. Source-local layers let two Sentry projects use different auth + * tokens without leaking either token into another repository's runs. + */ +export function buildErrorMonitorEnv( + source: ErrorMonitorConfig, + repo: RepoConfig, + team: TeamConfig | undefined, + workspaceDir: string = resolveWorkspaceDir(), +): Record { + const env = team ? buildTeamTaskEnv(repo, team, workspaceDir) : buildRepoEnv(repo, workspaceDir); + const sourceFileEnv = source.envFile + ? parseEnvFile(isAbsolute(source.envFile) ? source.envFile : join(workspaceDir, source.envFile)) + : {}; + return { ...env, ...sourceFileEnv, ...source.env }; +} diff --git a/packages/code/src/lib/workspace/init.ts b/packages/code/src/lib/workspace/init.ts index caa96be..5fd1a01 100644 --- a/packages/code/src/lib/workspace/init.ts +++ b/packages/code/src/lib/workspace/init.ts @@ -89,6 +89,21 @@ default_branch = "main" # repo = "web" # labels = ["frontend"] +# Error-monitoring projects map explicitly to their owning repository. Add one +# entry per Sentry project; source-local env files allow different tokens. +# ---- +# [[error_monitors]] +# id = "backend-production" +# provider = "sentry" +# repo = "backend" # required when multiple repos are configured +# team = "platform" # optional [[teams]] owner +# organization = "acme" +# project = "backend" +# query = "environment:production" +# min_occurrences = 5 +# max_per_tick = 3 +# env_file = "env/sentry-backend.env" # contains SENTRY_AUTH_TOKEN + # Recurring work is hot-reloaded: edits apply to the running worker without a # restart. Each occurrence runs the prompt through the normal task pipeline as # a local markdown task. diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index e413a73..675d87e 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -8,7 +8,7 @@ * in the central workspace DB. */ -import { existsSync } from "fs"; +import { existsSync, mkdirSync, writeFileSync } from "fs"; import { dirname, join, resolve } from "path"; import { LockManager } from "../lock-manager"; @@ -31,7 +31,13 @@ import { ScheduledRetryStore } from "../run-retry"; import type { TaskTrackerClient } from "../task-tracker-client"; import { findRepo, findTeam, loadWorkspaceConfig } from "./config"; import type { RepoConfig, TeamConfig, WorkspaceConfig } from "./config"; -import { buildRepoEnv, buildTeamEnv, buildTeamTaskEnv, parseEnvFile } from "./env"; +import { + buildErrorMonitorEnv, + buildRepoEnv, + buildTeamEnv, + buildTeamTaskEnv, + parseEnvFile, +} from "./env"; import { resolveWorkspaceDir, workspaceConfigPath, @@ -704,7 +710,8 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr !multiTeam && !initialQuery && config.automations.length === 0 && - config.estimations.length === 0 + config.estimations.length === 0 && + !config.errorMonitors.some((source) => source.enabled) ) { if (retryQueue.hasPending()) { console.warn( @@ -830,6 +837,50 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr }); acquirers.push(estimationAcquirer); + // Error-monitor adapters share one provider-neutral acquirer. Each source + // is pinned to a repo (and optionally a team), so projects with different + // credentials cannot be dispatched into the wrong codebase. + const { ErrorMonitorAcquirer, createErrorMonitorProvider } = await import("../error-monitor"); + const errorTaskDir = join(workspaceDir, "error-fixes"); + for (const source of config.errorMonitors) { + if (!source.enabled) continue; + const repo = findRepo(config, source.repo); + if (!repo) throw new Error(`Error monitor "${source.id}" references unknown repo.`); + const team = source.team ? findTeam(config, source.team) : undefined; + const env = buildErrorMonitorEnv(source, repo, team, workspaceDir); + + const provider = createErrorMonitorProvider(source, env); + const execute = createFleetTaskExecutor( + { + config, + workspaceDir, + skips: state.skips, + repoManager, + team, + coordinator, + }, + { repo: repo.name }, + ); + acquirers.push( + new ErrorMonitorAcquirer({ + sourceId: source.id, + intervalSeconds: source.intervalSeconds, + minOccurrences: source.minOccurrences, + maxIssuesPerTick: source.maxIssuesPerTick, + queue: state.queue, + provider, + verbose: options.verbose, + executeTask: async (issue, markdown) => { + mkdirSync(errorTaskDir, { recursive: true }); + const safeId = `${source.id}-${issue.displayId}`.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const taskFile = join(errorTaskDir, `${safeId}.md`); + writeFileSync(taskFile, markdown); + return execute(taskFile, { key: issue.externalId, labels: [], components: [] }); + }, + }), + ); + } + // Tracker identities and credentials are startup-only. Queries and fixed // team repo mappings stay live through lookups against the shared config. const { TaskTrackerManager, createTrackerClient, trackerRequiredEnv } = @@ -965,6 +1016,9 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr "Team names, trackers, env_file, and inline env are startup-only; restart the worker to change them.", ); } + if (JSON.stringify(next.errorMonitors) !== JSON.stringify(current.errorMonitors)) { + throw new Error("[[error_monitors]] is startup-only; restart the worker to change it."); + } if (!multiTeam && next.defaults.taskQuery && sources.length === 0) { throw new Error( `task_query cannot be enabled live because the ${current.defaults.tracker} change detector ` + diff --git a/packages/code/tests/sentry-acquirer.test.ts b/packages/code/tests/sentry-acquirer.test.ts new file mode 100644 index 0000000..39d3f58 --- /dev/null +++ b/packages/code/tests/sentry-acquirer.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { SentryClient } from "../src/lib/sentry-client"; +import type { SentryIssue } from "../src/lib/sentry-client"; +import { ErrorMonitorAcquirer } from "../src/lib/error-monitor"; +import { WebhookQueue } from "../src/lib/webhook-queue"; + +function issue(overrides: Partial = {}): SentryIssue { + return { + externalId: "issue:1001", + displayId: "APP-1", + occurrenceCount: 12, + id: "1001", + shortId: "APP-1", + title: "TypeError: cannot read properties of undefined", + culprit: "src/app.ts in handler", + level: "error", + status: "unresolved", + count: "12", + firstSeen: "2026-08-20T10:00:00Z", + lastSeen: "2026-08-21T09:00:00Z", + permalink: "https://sentry.io/organizations/acme/issues/1001/", + metadata: { type: "TypeError", value: "cannot read properties of undefined" }, + ...overrides, + }; +} + +describe("SentryClient", () => { + test("queries the project endpoint with auth and is:unresolved", async () => { + const requests: string[] = []; + const authHeaders: string[] = []; + const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => { + requests.push(String(input)); + const auth = (init?.headers as Record | undefined)?.Authorization; + if (auth) { + authHeaders.push(auth); + } + return new Response(JSON.stringify([issue()]), { status: 200 }); + }) as typeof fetch; + + const client = new SentryClient({ + authToken: "sntrys_test", + organization: "acme", + project: "webapp", + baseUrl: "https://sentry.example.com/", + fetchImpl, + }); + const issues = await client.fetchUnresolvedIssues(); + + expect(requests[0]).toContain("https://sentry.example.com/api/0/projects/acme/webapp/issues/"); + expect(requests[0]).toContain("query=is%3Aunresolved"); + expect(authHeaders[0]).toBe("Bearer sntrys_test"); + expect(issues).toHaveLength(1); + expect(issues[0]?.id).toBe("1001"); + }); + + test("falls back to the organization endpoint without a project", async () => { + const requests: string[] = []; + const fetchImpl = (async (input: string | URL | Request) => { + requests.push(String(input)); + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + const client = new SentryClient({ + authToken: "t", + organization: "acme", + fetchImpl, + }); + await client.fetchUnresolvedIssues("environment:production"); + + expect(requests[0]).toContain("/api/0/organizations/acme/issues/"); + expect(requests[0]).toContain("environment%3Aproduction"); + }); + + test("throws a clear error on bad credentials", async () => { + const fetchImpl = (async () => + new Response("denied", { status: 401 })) as unknown as typeof fetch; + const client = new SentryClient({ authToken: "bad", organization: "acme", fetchImpl }); + expect(client.fetchUnresolvedIssues()).rejects.toThrow("auth token"); + }); +}); + +describe("isIssueValid", () => { + test("accepts an issue with enough events and metadata", () => { + const client = new SentryClient({ authToken: "t", organization: "acme" }); + expect(client.validateIssue(issue()).valid).toBe(true); + }); + + test("rejects issues without any locating context", () => { + const client = new SentryClient({ authToken: "t", organization: "acme" }); + const verdict = client.validateIssue(issue({ culprit: null, metadata: undefined })); + expect(verdict.valid).toBe(false); + }); +}); + +describe("buildBugfixTaskMarkdown", () => { + test("includes the error details and fix instructions", () => { + const client = new SentryClient({ authToken: "t", organization: "acme" }); + const md = client.buildTaskMarkdown(issue()); + expect(md).toContain("# Fix Sentry error APP-1"); + expect(md).toContain("TypeError"); + expect(md).toContain("src/app.ts in handler"); + expect(md).toContain(issue().permalink); + }); +}); + +describe("ErrorMonitorAcquirer with Sentry issues", () => { + let dir: string; + let dbPath: string; + let queue: WebhookQueue; + + beforeEach(() => { + dir = join(tmpdir(), `sentry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + dbPath = join(dir, "queue.db"); + queue = new WebhookQueue({ dbPath }); + }); + + afterEach(() => { + queue.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + function makeAcquirer( + scriptedIssues: SentryIssue[] | Error, + overrides: Partial>[0]> = {}, + ): { acquirer: ErrorMonitorAcquirer; fixed: SentryIssue[] } { + const fixed: SentryIssue[] = []; + const provider = { + providerName: "sentry", + fetchIssues: async () => { + if (scriptedIssues instanceof Error) throw scriptedIssues; + return scriptedIssues; + }, + validateIssue: (item: SentryIssue) => + new SentryClient({ authToken: "t", organization: "acme" }).validateIssue(item), + buildTaskMarkdown: (item: SentryIssue) => + new SentryClient({ authToken: "t", organization: "acme" }).buildTaskMarkdown(item), + }; + const acquirer = new ErrorMonitorAcquirer({ + sourceId: "primary", + intervalSeconds: 60, + queue, + provider, + executeTask: async (i) => { + fixed.push(i); + return true; + }, + ...overrides, + }); + return { acquirer, fixed }; + } + + test("creates bugfixes for new valid issues and marks them processed", async () => { + const { acquirer, fixed } = makeAcquirer([issue()]); + await acquirer.tick(); + + expect(fixed).toHaveLength(1); + expect(fixed[0]?.shortId).toBe("APP-1"); + expect(queue.hasProcessed("errors:sentry:primary", "issue:1001")).toBe(true); + }); + + test("never processes the same error group twice", async () => { + const { acquirer, fixed } = makeAcquirer([issue()]); + await acquirer.tick(); + await acquirer.tick(); + + expect(fixed).toHaveLength(1); + }); + + test("only one competing acquirer submits an error group", async () => { + const competingQueue = new WebhookQueue({ dbPath }); + try { + const first = makeAcquirer([issue()]); + const second = makeAcquirer([issue()], { queue: competingQueue }); + + await Promise.all([first.acquirer.tick(), second.acquirer.tick()]); + + expect(first.fixed.length + second.fixed.length).toBe(1); + } finally { + competingQueue.close(); + } + }); + + test("skips invalid issues without marking them processed", async () => { + const { acquirer, fixed } = makeAcquirer([issue({ count: "1", occurrenceCount: 1 })]); + await acquirer.tick(); + + expect(fixed).toHaveLength(0); + expect(queue.hasProcessed("errors:sentry:primary", "issue:1001")).toBe(false); + }); + + test("marks before executing so failing runs do not loop every tick", async () => { + let attempts = 0; + const provider = { + providerName: "sentry", + fetchIssues: async () => [issue()], + validateIssue: () => ({ valid: true }), + buildTaskMarkdown: () => "task", + }; + const acquirer = new ErrorMonitorAcquirer({ + sourceId: "primary", + intervalSeconds: 60, + queue, + provider, + executeTask: async () => { + attempts++; + return false; + }, + }); + + await acquirer.tick(); + await acquirer.tick(); + + expect(attempts).toBe(1); + }); + + test("unclaims capacity-deferred issues so a later tick can retry", async () => { + let attempts = 0; + const { acquirer } = makeAcquirer([issue()], { + executeTask: async () => { + attempts++; + return attempts === 1 ? "deferred" : true; + }, + }); + + await acquirer.tick(); + expect(queue.hasProcessed("errors:sentry:primary", "issue:1001")).toBe(false); + await acquirer.tick(); + expect(attempts).toBe(2); + expect(queue.hasProcessed("errors:sentry:primary", "issue:1001")).toBe(true); + }); + + test("caps work per tick", async () => { + const many = [ + issue(), + issue({ id: "1002", shortId: "APP-2", externalId: "issue:1002", displayId: "APP-2" }), + issue({ id: "1003", shortId: "APP-3", externalId: "issue:1003", displayId: "APP-3" }), + ]; + const { acquirer, fixed } = makeAcquirer(many, { maxIssuesPerTick: 2 }); + await acquirer.tick(); + + expect(fixed).toHaveLength(2); + }); + + test("a failed fetch does not throw or mark anything", async () => { + const { acquirer, fixed } = makeAcquirer(new Error("network down")); + await acquirer.tick(); + + expect(fixed).toHaveLength(0); + expect(queue.hasProcessed("errors:sentry:primary", "issue:1001")).toBe(false); + }); +}); diff --git a/packages/code/tests/webhook-queue.test.ts b/packages/code/tests/webhook-queue.test.ts index 491b3b4..f5f6021 100644 --- a/packages/code/tests/webhook-queue.test.ts +++ b/packages/code/tests/webhook-queue.test.ts @@ -112,6 +112,11 @@ describe("WebhookQueue", () => { expect(queue.hasProcessed("github", "delivery-1")).toBe(true); }); + test("tryMarkProcessed grants a claim only once", () => { + expect(queue.tryMarkProcessed("github", "delivery-claim")).toBe(true); + expect(queue.tryMarkProcessed("github", "delivery-claim")).toBe(false); + }); + test("ids are scoped per source", () => { queue.markProcessed("github", "id-1"); expect(queue.hasProcessed("linear", "id-1")).toBe(false); diff --git a/packages/code/tests/workspace-config.test.ts b/packages/code/tests/workspace-config.test.ts index dcd5c17..554be83 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -703,6 +703,85 @@ interval = "1d" `), ).toThrow(/\[\[estimations\]\] uses \[defaults\]\.tracker/); }); + + test("parses repo- and team-scoped Sentry monitors with independent credentials", () => { + const config = parseWorkspaceConfig(` +[defaults] +tracker = "jira" +poll_interval = 90 + +[[teams]] +name = "platform" +tracker = "jira" +task_query = "project = PLAT" +repo = "api" + +[[repos]] +name = "api" +remote = "git@github.com:acme/api.git" + +[[repos]] +name = "web" +remote = "git@github.com:acme/web.git" + +[[error_monitors]] +id = "api-production" +provider = "sentry" +repo = "api" +team = "platform" +organization = "acme" +project = "api" +env_file = "env/sentry-api.env" +min_occurrences = 10 +max_per_tick = 2 + [error_monitors.env] + SENTRY_AUTH_TOKEN = "api-token" + +[[error_monitors]] +id = "web-production" +provider = "sentry" +repo = "web" +organization = "acme" +project = "web" +poll_interval = 30 +`); + + expect(config.errorMonitors).toHaveLength(2); + expect(config.errorMonitors[0]).toMatchObject({ + id: "api-production", + repo: "api", + team: "platform", + intervalSeconds: 90, + minOccurrences: 10, + maxIssuesPerTick: 2, + env: { SENTRY_AUTH_TOKEN: "api-token" }, + }); + expect(config.errorMonitors[1]).toMatchObject({ + id: "web-production", + repo: "web", + intervalSeconds: 30, + }); + }); + + test("requires explicit monitor routing in multi-repo workspaces", () => { + expect(() => + parseWorkspaceConfig(` +[[repos]] +name = "api" +remote = "git@github.com:acme/api.git" + +[[repos]] +name = "web" +remote = "git@github.com:acme/web.git" + +[[error_monitors]] +id = "production" +provider = "sentry" +organization = "acme" +project = "web" +`), + ).toThrow(/repo is required in a workspace with multiple repositories/); + }); }); describe("parseWorkspaceConfig [worker.schedule] (quiet hours)", () => { diff --git a/packages/code/tests/workspace-env.test.ts b/packages/code/tests/workspace-env.test.ts index 9349732..898fd48 100644 --- a/packages/code/tests/workspace-env.test.ts +++ b/packages/code/tests/workspace-env.test.ts @@ -3,8 +3,13 @@ import { mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import type { RepoConfig } from "../src/lib/workspace/config"; -import { buildRepoEnv, gitHubSlugFromRemote, parseEnvFile } from "../src/lib/workspace/env"; +import type { ErrorMonitorConfig, RepoConfig, TeamConfig } from "../src/lib/workspace/config"; +import { + buildErrorMonitorEnv, + buildRepoEnv, + gitHubSlugFromRemote, + parseEnvFile, +} from "../src/lib/workspace/env"; describe("gitHubSlugFromRemote", () => { test("parses ssh and https GitHub remotes", () => { @@ -94,6 +99,37 @@ describe("buildRepoEnv", () => { expect(unset.PR_LABELS).toBe("from-env"); }); + test("error monitor credentials are isolated per source and override team/repo layers", () => { + writeFileSync(join(workspaceDir, ".env"), "SENTRY_AUTH_TOKEN=workspace\n"); + mkdirSync(join(workspaceDir, "env"), { recursive: true }); + writeFileSync(join(workspaceDir, "env", "sentry.env"), "SENTRY_AUTH_TOKEN=source-file\n"); + const team: TeamConfig = { + name: "platform", + tracker: "jira", + taskQuery: "project = PLAT", + env: { SENTRY_AUTH_TOKEN: "team" }, + }; + const source: ErrorMonitorConfig = { + id: "api-production", + provider: "sentry", + enabled: true, + repo: "backend", + team: "platform", + organization: "acme", + project: "api", + intervalSeconds: 60, + minOccurrences: 5, + maxIssuesPerTick: 3, + envFile: "env/sentry.env", + env: { SENTRY_AUTH_TOKEN: "source-inline" }, + }; + + const env = buildErrorMonitorEnv(source, repo(), team, workspaceDir); + expect(env.SENTRY_AUTH_TOKEN).toBe("source-inline"); + expect(env.DEVINTERN_WORKSPACE_REPO).toBe("backend"); + expect(env.DEVINTERN_WORKSPACE_TEAM).toBe("platform"); + }); + test("parseEnvFile ignores comments, blanks, and strips quotes", () => { const path = join(workspaceDir, "sample.env"); writeFileSync(path, "# comment\n\nA=1\nB='two'\nC=a=b\nBROKEN\n");