From b224294b0eabdcf5a0d69f4887890275ab6cf18d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 11:03:32 -0700 Subject: [PATCH] Add runtime issue clear action --- foundry/CLAUDE.md | 2 ++ foundry/packages/backend/CLAUDE.md | 1 + .../backend/src/actors/organization/actions.ts | 6 +++++- .../backend/src/actors/organization/app-shell.ts | 11 +++++++++++ .../backend/src/actors/runtime-issues.ts | 13 ++++++++++++- foundry/packages/backend/src/index.ts | 15 +++++++++++++++ foundry/packages/client/src/app-client.ts | 1 + foundry/packages/client/src/backend-client.ts | 10 ++++++++++ foundry/packages/client/src/mock-app.ts | 16 ++++++++++++++++ .../packages/client/src/mock/backend-client.ts | 4 ++++ foundry/packages/client/src/remote/app-client.ts | 5 +++++ 11 files changed, 82 insertions(+), 2 deletions(-) diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index e7c28a5..dc14906 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -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. diff --git a/foundry/packages/backend/CLAUDE.md b/foundry/packages/backend/CLAUDE.md index 4616346..cb21c4f 100644 --- a/foundry/packages/backend/CLAUDE.md +++ b/foundry/packages/backend/CLAUDE.md @@ -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 diff --git a/foundry/packages/backend/src/actors/organization/actions.ts b/foundry/packages/backend/src/actors/organization/actions.ts index 69b0b5f..37c5475 100644 --- a/foundry/packages/backend/src/actors/organization/actions.ts +++ b/foundry/packages/backend/src/actors/organization/actions.ts @@ -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 { + await clearOrganizationActorRuntimeIssues(c, input); + }, + async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> { assertWorkspace(c, input.workspaceId); return { workspaceId: c.state.workspaceId }; diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index 24a781d..21bd349 100644 --- a/foundry/packages/backend/src/actors/organization/app-shell.ts +++ b/foundry/packages/backend/src/actors/organization/app-shell.ts @@ -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 { + 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); diff --git a/foundry/packages/backend/src/actors/runtime-issues.ts b/foundry/packages/backend/src/actors/runtime-issues.ts index d156fe2..f90c23c 100644 --- a/foundry/packages/backend/src/actors/runtime-issues.ts +++ b/foundry/packages/backend/src/actors/runtime-issues.ts @@ -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 right.occurredAt - left.occurredAt); } +export async function clearActorRuntimeIssues(c: any, input?: { actorId?: string | null }): Promise { + 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; diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 0f1c3bc..05bb4b5 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -270,6 +270,21 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + 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( diff --git a/foundry/packages/client/src/app-client.ts b/foundry/packages/client/src/app-client.ts index 1fb95d2..b0229d0 100644 --- a/foundry/packages/client/src/app-client.ts +++ b/foundry/packages/client/src/app-client.ts @@ -19,6 +19,7 @@ export interface FoundryAppClient { selectOrganization(organizationId: string): Promise; updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; triggerGithubSync(organizationId: string): Promise; + clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise; completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise; openBillingPortal(organizationId: string): Promise; cancelScheduledRenewal(organizationId: string): Promise; diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index ba5cf4a..5de8c28 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -163,6 +163,7 @@ export interface BackendClient { selectAppOrganization(organizationId: string): Promise; updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; triggerAppRepoImport(organizationId: string): Promise; + clearAppOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise; reconnectAppGithub(organizationId: string): Promise; completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise; openAppBillingPortal(organizationId: string): Promise; @@ -769,6 +770,15 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }); }, + async clearAppOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/runtime-issues/clear`, { + method: "POST", + body: JSON.stringify({ + actorId: actorId ?? null, + }), + }); + }, + async reconnectAppGithub(organizationId: string): Promise { await redirectTo(`/app/organizations/${organizationId}/reconnect`, { method: "POST", diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 88a2167..a5922d9 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -133,6 +133,7 @@ export interface MockFoundryAppClient { selectOrganization(organizationId: string): Promise; updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise; triggerGithubSync(organizationId: string): Promise; + clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise; completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise; openBillingPortal(organizationId: string): Promise; cancelScheduledRenewal(organizationId: string): Promise; @@ -585,6 +586,21 @@ class MockFoundryAppStore implements MockFoundryAppClient { this.importTimers.set(organizationId, timer); } + async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise { + 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 { await this.injectAsyncLatency(); this.requireOrganization(organizationId); diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index 4e6a18b..acc92fd 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -253,6 +253,10 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return unsupportedAppSnapshot(); }, + async clearAppOrganizationRuntimeIssues(): Promise { + return unsupportedAppSnapshot(); + }, + async reconnectAppGithub(): Promise { notSupported("reconnectAppGithub"); }, diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts index e381540..67830bb 100644 --- a/foundry/packages/client/src/remote/app-client.ts +++ b/foundry/packages/client/src/remote/app-client.ts @@ -80,6 +80,11 @@ class RemoteFoundryAppStore implements FoundryAppClient { this.scheduleSyncPollingIfNeeded(); } + async clearOrganizationRuntimeIssues(organizationId: string, actorId?: string): Promise { + this.snapshot = await this.backend.clearAppOrganizationRuntimeIssues(organizationId, actorId); + this.notify(); + } + async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise { await this.backend.completeAppHostedCheckout(organizationId, planId); }