Add runtime issue clear action

This commit is contained in:
Nathan Flurry 2026-03-12 11:03:32 -07:00
parent ec8e816d0d
commit b224294b0e
11 changed files with 82 additions and 2 deletions

View file

@ -180,6 +180,7 @@ For all Rivet/RivetKit implementation:
- Organization grouping is managed by the GitHub organization structure. Do not introduce a second internal grouping model that can diverge from GitHub.
- For workflow-backed actors, install a workflow `onError` hook and report failures into organization-scoped runtime issue state so the frontend can surface actor/workflow errors without querying the entire actor tree live.
- The main workspace top bar should make organization runtime errors obvious. If actor/workflow errors exist, show them there and include detailed issue state in settings.
- Expose and use an explicit clear-errors action for organization runtime issues after triage. Do not reset stale actor errors by hand-editing actor DB state.
## Testing Policy
@ -196,6 +197,7 @@ For all Rivet/RivetKit implementation:
- Merge the PR.
- Confirm the task is finished and its status is updated correctly.
- During this flow, verify that remote GitHub state updates correctly and that Foundry receives and applies the resulting webhook-driven state updates.
- If the test leaves stale runtime issues behind after investigation, use the clear-errors action before the next run.
- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime.
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.

View file

@ -40,6 +40,7 @@ AppShellOrganization("app")
- Workflow handlers should be decomposed into small durable steps. Each local mutation or externally meaningful transition gets its own step; avoid monolithic workflow steps that bundle an entire cross-actor flow together.
- Every actor that uses `workflow(...)` must install an `onError` hook and report normalized workflow failures into organization-scoped runtime issue state.
- Organization runtime issue state is the backend source of truth for actor/workflow error badges in the frontend top bar and settings screens.
- Provide an explicit action to clear recorded organization runtime issues after investigation. Use that action instead of manual DB edits when resetting stale actor errors in dev.
## Maintenance

View file

@ -34,7 +34,7 @@ import type {
import { getActorRuntimeContext } from "../context.js";
import { getOrCreateGithubState, getOrCreateHistory, getOrCreateRepository, getOrCreateTask, getTask, selfOrganization } from "../handles.js";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { upsertActorRuntimeIssue } from "../runtime-issues.js";
import { clearActorRuntimeIssues as clearOrganizationActorRuntimeIssues, upsertActorRuntimeIssue } from "../runtime-issues.js";
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
@ -468,6 +468,10 @@ export const workspaceActions = {
await upsertActorRuntimeIssue(c, input);
},
async clearActorRuntimeIssues(c: any, input?: { actorId?: string | null }): Promise<void> {
await clearOrganizationActorRuntimeIssues(c, input);
},
async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> {
assertWorkspace(c, input.workspaceId);
return { workspaceId: c.state.workspaceId };

View file

@ -806,6 +806,17 @@ export const workspaceAppActions = {
return await buildAppSnapshot(c, input.sessionId);
},
async clearAppOrganizationRuntimeIssues(c: any, input: { sessionId: string; organizationId: string; actorId?: string | null }): Promise<FoundryAppSnapshot> {
assertAppWorkspace(c);
const { profile } = await requireSignedInSession(c, input.sessionId);
requireEligibleOrganization(profile, input.organizationId);
const workspace = await getOrCreateOrganization(c, input.organizationId);
await workspace.clearActorRuntimeIssues({
actorId: input.actorId ?? null,
});
return await buildAppSnapshot(c, input.sessionId);
},
async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
assertAppWorkspace(c);
const { profile } = await requireSignedInSession(c, input.sessionId);

View file

@ -1,6 +1,6 @@
import type { WorkflowErrorEvent } from "rivetkit/workflow";
import type { FoundryActorRuntimeIssue, FoundryActorRuntimeType } from "@sandbox-agent/foundry-shared";
import { sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { organizationActorIssues } from "./organization/db/schema.js";
import { getOrCreateOrganization } from "./handles.js";
@ -98,6 +98,17 @@ export async function listActorRuntimeIssues(c: any): Promise<ActorRuntimeIssueR
.sort((left, right) => right.occurredAt - left.occurredAt);
}
export async function clearActorRuntimeIssues(c: any, input?: { actorId?: string | null }): Promise<void> {
await ensureOrganizationActorIssuesTable(c);
const actorId = input?.actorId?.trim();
if (actorId) {
await c.db.delete(organizationActorIssues).where(eq(organizationActorIssues.actorId, actorId)).run();
return;
}
await c.db.delete(organizationActorIssues).run();
}
function normalizeWorkflowIssue(event: WorkflowErrorEvent): NormalizedWorkflowIssue {
if ("step" in event) {
const error = event.step.error;

View file

@ -270,6 +270,21 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
);
});
app.post("/api/rivet/app/organizations/:organizationId/runtime-issues/clear", async (c) => {
const sessionId = await resolveSessionId(c);
const body = await c.req.json().catch(() => ({}));
return c.json(
await appWorkspaceAction(
async (workspace) =>
await workspace.clearAppOrganizationRuntimeIssues({
sessionId,
organizationId: c.req.param("organizationId"),
actorId: typeof body?.actorId === "string" ? body.actorId : null,
}),
),
);
});
app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => {
const sessionId = await resolveSessionId(c);
return c.json(

View file

@ -19,6 +19,7 @@ export interface FoundryAppClient {
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;

View file

@ -163,6 +163,7 @@ export interface BackendClient {
selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot>;
clearAppOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<FoundryAppSnapshot>;
reconnectAppGithub(organizationId: string): Promise<void>;
completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
openAppBillingPortal(organizationId: string): Promise<void>;
@ -769,6 +770,15 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
});
},
async clearAppOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/runtime-issues/clear`, {
method: "POST",
body: JSON.stringify({
actorId: actorId ?? null,
}),
});
},
async reconnectAppGithub(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/reconnect`, {
method: "POST",

View file

@ -133,6 +133,7 @@ export interface MockFoundryAppClient {
selectOrganization(organizationId: string): Promise<void>;
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
triggerGithubSync(organizationId: string): Promise<void>;
clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void>;
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
openBillingPortal(organizationId: string): Promise<void>;
cancelScheduledRenewal(organizationId: string): Promise<void>;
@ -585,6 +586,21 @@ class MockFoundryAppStore implements MockFoundryAppClient {
this.importTimers.set(organizationId, timer);
}
async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
await this.injectAsyncLatency();
void actorId;
this.requireOrganization(organizationId);
this.updateOrganization(organizationId, (organization) => ({
...organization,
runtime: {
...organization.runtime,
status: "healthy",
errorCount: 0,
issues: [],
},
}));
}
async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void> {
await this.injectAsyncLatency();
this.requireOrganization(organizationId);

View file

@ -253,6 +253,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
return unsupportedAppSnapshot();
},
async clearAppOrganizationRuntimeIssues(): Promise<FoundryAppSnapshot> {
return unsupportedAppSnapshot();
},
async reconnectAppGithub(): Promise<void> {
notSupported("reconnectAppGithub");
},

View file

@ -80,6 +80,11 @@ class RemoteFoundryAppStore implements FoundryAppClient {
this.scheduleSyncPollingIfNeeded();
}
async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise<void> {
this.snapshot = await this.backend.clearAppOrganizationRuntimeIssues(organizationId, actorId);
this.notify();
}
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await this.backend.completeAppHostedCheckout(organizationId, planId);
}