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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/api-client/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,30 @@ export interface paths {
patch: operations["patch_api_v1_runs_runId"];
trace?: never;
};
"/api/v1/runs/{runId}/report-error": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Report an error for an active run
* @description Records a sanitized reason and authenticated reporter without changing run execution or releasing its concurrency slot. The agent may continue work and should then exit normally. An identical retry succeeds; a different replacement reason returns 409. Terminal runs reject reports.
*
* Requires the runs:write scope.
*
* Accepts a scoped bearer token or interactive owner session.
*/
post: operations["post_api_v1_runs_runId_report_error"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{runId}/trace": {
parameters: {
query?: never;
Expand Down Expand Up @@ -1697,6 +1721,17 @@ export interface components {
created_at: string;
updated_at: string;
started_at: string | null;
failure_source: ("system" | "agent") | null;
agent_error: {
reason: string;
reported_at: string;
reporter: {
user_id: string;
/** @enum {string} */
kind: "api_key" | "oauth" | "session";
id: string;
};
} | null;
elapsed_time?: ({
duration: number;
} | {
Expand Down Expand Up @@ -1732,6 +1767,17 @@ export interface components {
created_at: string;
updated_at: string;
started_at: string | null;
failure_source: ("system" | "agent") | null;
agent_error: {
reason: string;
reported_at: string;
reporter: {
user_id: string;
/** @enum {string} */
kind: "api_key" | "oauth" | "session";
id: string;
};
} | null;
elapsed_time?: ({
duration: number;
} | {
Expand Down Expand Up @@ -6206,6 +6252,17 @@ export interface operations {
artifact_state: "pending" | "collecting" | "stored" | "partial" | "failed";
finalizing: boolean;
trace_revision: string;
failure_source: ("system" | "agent") | null;
agent_error: {
reason: string;
reported_at: string;
reporter: {
user_id: string;
/** @enum {string} */
kind: "api_key" | "oauth" | "session";
id: string;
};
} | null;
};
};
};
Expand Down Expand Up @@ -6346,6 +6403,52 @@ export interface operations {
503: components["responses"]["Error503"];
};
};
post_api_v1_runs_runId_report_error: {
parameters: {
query?: never;
header?: never;
path: {
runId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
reason: string;
};
};
};
responses: {
/** @description Report saved or identical retry acknowledged */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
reported: true;
reason: string;
retry: boolean;
};
};
};
/** @description Invalid reason or request body */
400: components["responses"]["Error400"];
401: components["responses"]["Error401"];
/** @description Missing runs:write authorization */
403: components["responses"]["Error403"];
/** @description Run not found within the tenant */
404: components["responses"]["Error404"];
/** @description Run is terminal or a different reason was already reported */
409: components["responses"]["Error409"];
415: components["responses"]["Error415"];
429: components["responses"]["Error429"];
500: components["responses"]["Error500"];
503: components["responses"]["Error503"];
};
};
get_api_v1_runs_runId_trace: {
parameters: {
query?: {
Expand Down
17 changes: 17 additions & 0 deletions packages/api/migrations/0025_agent_run_error.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ALTER TABLE app.runs
ADD COLUMN failure_source text,
ADD COLUMN agent_error_reason text,
ADD COLUMN agent_error_reported_at timestamptz,
ADD COLUMN agent_error_reporter_user_id uuid,
ADD COLUMN agent_error_reporter_kind text,
ADD COLUMN agent_error_reporter_id text;

UPDATE app.runs SET failure_source='system' WHERE state='failed';

ALTER TABLE app.runs ADD CONSTRAINT runs_failure_source_check
CHECK (failure_source IS NULL OR failure_source IN ('system','agent'));
ALTER TABLE app.runs ADD CONSTRAINT runs_failure_source_state_check
CHECK (failure_source IS NULL OR state='failed');
ALTER TABLE app.runs ADD CONSTRAINT runs_agent_error_report_check
CHECK ((agent_error_reason IS NULL AND agent_error_reported_at IS NULL AND agent_error_reporter_user_id IS NULL AND agent_error_reporter_kind IS NULL AND agent_error_reporter_id IS NULL)
OR (agent_error_reason IS NOT NULL AND agent_error_reported_at IS NOT NULL AND agent_error_reporter_user_id IS NOT NULL AND agent_error_reporter_kind IS NOT NULL AND agent_error_reporter_id IS NOT NULL));
14 changes: 14 additions & 0 deletions packages/api/src/api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
jobInputSchema,
manualInvocationSchema,
renameRunSchema,
reportRunErrorSchema,
reportRunErrorResponse,
jobConditionsTestSchema,
listRunsSchema,
scopeSchema,
Expand Down Expand Up @@ -155,6 +157,7 @@ function route(
// Legacy route labels are translated by resource and effect; they are never
// accepted as newly issued scopes.
function scopeForRoute(path: string, legacy: string): string | string[] {
if (legacy === "runs:write" && path.endsWith("/report-error")) return "runs:write";
if (legacy === "runs:write" && path === "/api/v1/runs/{runId}" ) return "runs:write";
if (legacy === "runs:read" && path === "/api/v1/search") return "jobs:read+runs:read";
if (legacy === "flows:read") {
Expand Down Expand Up @@ -894,6 +897,17 @@ export const API_ROUTES: RouteContract[] = [
description: "Changes only the run display name. Supply expectedName for optimistic concurrency; omission supports last-writer-wins clients.",
responses: { "200": { description: "Saved run display name", content: { "application/json": { schema: { type: "object", required: ["run_name"], properties: { run_name: { type: "string" } }, additionalProperties: false } } } }, "404": { description: "Run not found within the tenant" }, "409": { description: "stale_run_name: reload before retrying" } },
}, (service, { params, body }) => service.renameRun(params.runId, body), { body: renameRunSchema, parameters: z.object({ runId: z.uuid() }) }),
route("POST", "/api/v1/runs/{runId}/report-error", "runs:write", {
summary: "Report an error for an active run",
description: "Records a sanitized reason and authenticated reporter without changing run execution or releasing its concurrency slot. The agent may continue work and should then exit normally. An identical retry succeeds; a different replacement reason returns 409. Terminal runs reject reports.",
responses: {
"200": { description: "Report saved or identical retry acknowledged", content: { "application/json": { schema: jsonSchema(reportRunErrorResponse, "output") } } },
"400": { description: "Invalid reason or request body" },
"403": { description: "Missing runs:write authorization" },
"404": { description: "Run not found within the tenant" },
"409": { description: "Run is terminal or a different reason was already reported" },
},
}, (service, { params, body }) => service.reportRunError(params.runId, body), { body: reportRunErrorSchema, maxBodyBytes: 4096, parameters: z.object({ runId: z.uuid() }) }),
route(
"GET",
"/api/v1/runs/{runId}/trace",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/device-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export async function decideDevice(env: Env, oauth: OAuthHelpers, session: { ten
const authorize = async (record: DeviceRecord | null, write: (record: DeviceRecord) => Promise<void>) => {
if (!record || record.status !== "pending" || record.expiresAt <= Math.floor(Date.now() / 1000)) return null;
if (decision === "deny") { record.status = "denied"; await write(record); return { status: "denied" as const }; }
const props: OAuthProps = { tenantId: session.tenantId, userId: session.userId, sessionVersion: session.sessionVersion, scopes: record.scope, authMethod: "oauth" };
const props: OAuthProps = { tenantId: session.tenantId, userId: session.userId, sessionVersion: session.sessionVersion, scopes: record.scope, authMethod: "oauth" };
const authRequest: AuthRequest = { responseType: "code", clientId: record.clientId, redirectUri: record.redirectUri, scope: record.scope, state: "", codeChallenge: record.codeChallenge, codeChallengeMethod: "S256", resource: record.resource, issuer: env.APP_ORIGIN };
const result = await oauth.completeAuthorization({ request: authRequest, userId: session.userId, metadata: { tenantId: session.tenantId }, scope: record.scope, props, revokeExistingGrants: false });
record.authorizationCode = new URL(result.redirectTo).searchParams.get("code") ?? undefined;
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/flow-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const listRunsSchema = z.object({
}).refine(value => !value.direction || value.sort, { message: "direction requires sort" });
export const runIdSchema = z.object({ runId: z.string().min(1) });
export const renameRunSchema = z.object({ name: z.string().trim().min(1).max(120), expectedName: z.string().max(120).optional() }).strict();
export const reportRunErrorSchema = z.object({ reason: z.string().trim().min(1).max(2000) }).strict();
export const reportRunErrorResponse = z.object({ reported: z.literal(true), reason: z.string(), retry: z.boolean() });

const webhookCommonSchema = z.object({ conditions: conditionsSchema.optional() });
export const webhookTriggerConfigSchema = z.discriminatedUnion("provider", [
Expand Down
22 changes: 19 additions & 3 deletions packages/api/src/flow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
import { runPageResponse, fullRunResponse } from "./run-read-contracts";
import { triggerContextCatalog } from "./trigger-context";
import type { Env, OAuthProps } from "./types";
import { canonicalizeVmImage, renameRunSchema, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { canonicalizeVmImage, renameRunSchema, reportRunErrorSchema, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { nextOccurrence, validateScheduleConfig } from "./schedule";
import { databaseFor } from "./postgres/database";
import { IdentityRepository } from "./postgres/identity-repository";
Expand Down Expand Up @@ -35,6 +35,8 @@ import { ProjectRepository } from "./postgres/project-repository";
import { effortOptions, selectableExeTags, type ExeConnection } from "./exe";
import { ExeImageVerificationError, ExeVmBackend } from "./exe-vm-backend";
import { SkillRepository, SkillConflict, SkillReferenced } from "./postgres/skill-repository";
import { RunRepository } from "./postgres/run-repository";
import { safeDiagnosticText } from "./harness-diagnostics";
import { skillInput, skillUpdateInput, skillPage, skillResponse, skillJobsResponse, previewSkillInput, composePrompt } from "./skills";

export class ServiceError extends Error {
Expand Down Expand Up @@ -157,6 +159,19 @@ export class ApiService {
async search(query: string) { await this.authorize("jobs:read"); await this.authorize("runs:read"); return publicValue(searchResponse, await new OperationsRepository(databaseFor(this.env), this.auth.tenantId).search(query)); }
async triggerContextMetadata() { await this.authorize("jobs:read"); return triggerContextCatalog; }
async getRunStatus(runId: string) { await this.authorize("runs:read"); const status = await new RunQueryRepository(databaseFor(this.env), this.auth.tenantId).status(runId); if (!status) throw new ServiceError(404, "not_found", "Run not found"); return status; }
async reportRunError(runId: string, input: unknown) {
await this.authorize("runs:write");
const parsed = reportRunErrorSchema.parse(input), reason = safeDiagnosticText(parsed.reason, 1000);
if (!reason.trim()) throw new ServiceError(400, "invalid_request", "Reason must contain safe text.");
const reporterKind = this.auth.authMethod === "oauth" ? "oauth" : this.auth.authMethod === "access_token" ? "api_key" : "session";
const reporterId = reporterKind === "oauth" ? this.auth.oauthGrantId && this.auth.oauthClientId ? `${this.auth.oauthClientId}:${this.auth.oauthGrantId}` : undefined : reporterKind === "api_key" ? this.auth.accessTokenId : this.auth.userId;
if (!reporterId) throw new ServiceError(403, "invalid_token", "Authenticated reporter identity is unavailable.");
const result = await new RunRepository(databaseFor(this.env)).reportAgentError({ tenantId: this.auth.tenantId, runId, reason, userId: this.auth.userId, reporterKind, reporterId });
if (result === "missing") throw new ServiceError(404, "not_found", "Run not found");
if (result === "terminal") throw new ServiceError(409, "terminal_run", "Run is already terminal and cannot accept an error report.");
if (result === "conflict") throw new ServiceError(409, "error_already_reported", "A different error reason was already reported for this run.");
return { reported: true, reason, retry: result === "identical" };
}
async renameRun(runId: string, input: unknown) {
await this.authorize("runs:write");
const parsed = renameRunSchema.parse(input);
Expand All @@ -183,11 +198,12 @@ export class ApiService {
run.trace_projection = projection.rows[0] ?? null; run.trace_generation = cursor.rows[0]?.generation ?? null;
run.harness_log = artifacts.find(item => item.kind === "terminal_log") ?? null;
// Enumerate public fields rather than publishing future database columns.
const agentError = run.agent_error_reason ? { reason: run.agent_error_reason, reported_at: run.agent_error_reported_at, reporter: { user_id: run.agent_error_reporter_user_id, kind: run.agent_error_reporter_kind, id: run.agent_error_reporter_id } } : null;
return publicValue(fullRunResponse, JSON.parse(JSON.stringify({ id: run.id, tenant_id: run.tenant_id, job_id: run.job_id, invocation_id: run.invocation_id,
execution_handle: run.execution_handle ? { backendKind: run.execution_handle.backendKind, id: run.execution_handle.id } : null,
provider: run.provider, issue_id: run.issue_id, issue_url: run.issue_url, issue_title: run.issue_title,
run_name: run.run_name, agent_name: run.agent_name, workspace_name: run.workspace_name, agent_kind: run.agent_kind,
state: run.state, execution_backend_kind: run.execution_backend_kind, backend_kind: run.backend_kind,
state: run.state, failure_source: run.failure_source ?? null, agent_error: agentError, execution_backend_kind: run.execution_backend_kind, backend_kind: run.backend_kind,
execution_capabilities: run.execution_capabilities, capabilities: run.capabilities, destination_url: run.destination_url,
artifact_state: run.artifact_state, artifact_error: run.artifact_error,
claim_released: run.claim_released, vm_cleanup_attempt: run.vm_cleanup_attempt, cleanup_next_at: run.cleanup_next_at, vm_cleanup_complete: run.vm_cleanup_complete,
Expand All @@ -205,7 +221,7 @@ export class ApiService {
new ArtifactRepository(database, this.auth.tenantId).list(runId),
]);
const primary = run.trace_sources?.primary ?? null;
return { runId: run.id, jobId: run.job_id, state: run.state, provider: run.provider, backendKind: run.execution_backend_kind,
return { runId: run.id, jobId: run.job_id, state: run.state, failureSource: run.failure_source, agentError: run.agent_error, provider: run.provider, backendKind: run.execution_backend_kind,
execution: run.execution_diagnostics, traceSources: run.trace_sources,
trace: { ...evidence, provider: primary?.provider ?? artifacts.find(item => item.kind === "native_session")?.provider ?? run.agent_kind ?? null, primarySource: primary?.kind ?? "native_session", sourceVersion: primary?.formatVersion ?? null,
artifacts: artifacts.filter(item => ["execution_stream", "native_session"].includes(item.kind)),
Expand Down
Loading
Loading