chore(foundry): migrate to actions (#262)

* feat(foundry): checkpoint actor and workspace refactor

* docs(foundry): add agent handoff context

* wip(foundry): continue actor refactor

* wip(foundry): capture remaining local changes

* Complete Foundry refactor checklist

* Fix Foundry validation fallout

* wip

* wip: convert all actors from workflow to plain run handlers

Workaround for RivetKit bug where c.queue.iter() never yields messages
for actors created via getOrCreate from another actor's context. The
queue accepts messages (visible in inspector) but the iterator hangs.
Sleep/wake fixes it, but actors with active connections never sleep.

Converted organization, github-data, task, and user actors from
run: workflow(...) to plain run: async (c) => { for await ... }.

Also fixes:
- Missing auth tables in org migration (auth_verification etc)
- default_model NOT NULL constraint on org profile upsert
- Nested workflow step in github-data (HistoryDivergedError)
- Removed --force from frontend Dockerfile pnpm install

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Convert all actors from queues/workflows to direct actions, lazy task creation

Major refactor replacing all queue-based workflow communication with direct
RivetKit action calls across all actors. This works around a RivetKit bug
where c.queue.iter() deadlocks for actors created from another actor's context.

Key changes:
- All actors (organization, task, user, audit-log, github-data) converted
  from run: workflow(...) to actions-only (no run handler, no queues)
- PR sync creates virtual task entries in org local DB instead of spawning
  task actors — prevents OOM from 200+ actors created simultaneously
- Task actors created lazily on first user interaction via getOrCreate,
  self-initialize from org's getTaskIndexEntry data
- Removed requireRepoExists cross-actor call (caused 500s), replaced with
  local resolveTaskRepoId from org's taskIndex table
- Fixed getOrganizationContext to thread overrides through all sync phases
- Fixed sandbox repo path (/home/user/repo for E2B compatibility)
- Fixed buildSessionDetail to skip transcript fetch for pending sessions
- Added process crash protection (uncaughtException/unhandledRejection)
- Fixed React infinite render loop in mock-layout useEffect dependencies
- Added sandbox listProcesses error handling for expired E2B sandboxes
- Set E2B sandbox timeout to 1 hour (was 5 min default)
- Updated CLAUDE.md with lazy task creation rules, no-silent-catch policy,
  React hook dependency safety rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements

- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
  resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
  organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 15:23:59 -07:00 committed by GitHub
parent 32f3c6c3bc
commit f45a467484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 9768 additions and 7204 deletions

View file

@ -0,0 +1,5 @@
import { db } from "rivetkit/db/drizzle";
import * as schema from "./schema.js";
import migrations from "./migrations.js";
export const auditLogDb = db({ schema, migrations });

View file

@ -0,0 +1,6 @@
import { defineConfig } from "rivetkit/db/drizzle";
export default defineConfig({
out: "./src/actors/audit-log/db/drizzle",
schema: "./src/actors/audit-log/db/schema.ts",
});

View file

@ -0,0 +1,8 @@
CREATE TABLE `events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`task_id` text,
`branch_name` text,
`kind` text NOT NULL,
`payload_json` text NOT NULL,
`created_at` integer NOT NULL
);

View file

@ -0,0 +1 @@
ALTER TABLE `events` ADD COLUMN `repo_id` text;

View file

@ -0,0 +1,70 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e592c829-141f-4740-88b7-09cf957a4405",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payload_json": {
"name": "payload_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,77 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a1b2c3d4-0001-4000-8000-000000000001",
"prevId": "e592c829-141f-4740-88b7-09cf957a4405",
"tables": {
"events": {
"name": "events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"repo_id": {
"name": "repo_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"branch_name": {
"name": "branch_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"payload_json": {
"name": "payload_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773376223815,
"tag": "0000_fluffy_kid_colt",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773376223816,
"tag": "0001_add_repo_id",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,37 @@
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
// Do not hand-edit this file.
const journal = {
entries: [
{
idx: 0,
when: 1773376223815,
tag: "0000_fluffy_kid_colt",
breakpoints: true,
},
{
idx: 1,
when: 1773376223816,
tag: "0001_add_repo_id",
breakpoints: true,
},
],
} as const;
export default {
journal,
migrations: {
m0000: `CREATE TABLE \`events\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`task_id\` text,
\`branch_name\` text,
\`kind\` text NOT NULL,
\`payload_json\` text NOT NULL,
\`created_at\` integer NOT NULL
);
`,
m0001: `ALTER TABLE \`events\` ADD COLUMN \`repo_id\` text;
`,
} as const,
};

View file

@ -0,0 +1,12 @@
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
export const events = sqliteTable("events", {
id: integer("id").primaryKey({ autoIncrement: true }),
repoId: text("repo_id"),
taskId: text("task_id"),
branchName: text("branch_name"),
kind: text("kind").notNull(),
// Structured by the audit-log event kind definitions in application code.
payloadJson: text("payload_json").notNull(),
createdAt: integer("created_at").notNull(),
});

View file

@ -0,0 +1,98 @@
// @ts-nocheck
import { and, desc, eq } from "drizzle-orm";
import { actor } from "rivetkit";
import type { AuditLogEvent } from "@sandbox-agent/foundry-shared";
import { auditLogDb } from "./db/db.js";
import { events } from "./db/schema.js";
export interface AuditLogInput {
organizationId: string;
}
export interface AppendAuditLogCommand {
kind: string;
repoId?: string;
taskId?: string;
branchName?: string;
payload: Record<string, unknown>;
}
export interface ListAuditLogParams {
repoId?: string;
branch?: string;
taskId?: string;
limit?: number;
}
/**
* Organization-scoped audit log. One per org, not one per repo.
*
* The org is the coordinator for all tasks across repos, and we frequently need
* to query the full audit trail across repos (e.g. org-wide activity feed,
* compliance). A per-repo audit log would require fan-out reads every time.
* Keeping it org-scoped gives us a single queryable feed with optional repoId
* filtering when callers want a narrower view.
*/
export const auditLog = actor({
db: auditLogDb,
options: {
name: "Audit Log",
icon: "database",
},
createState: (_c, input: AuditLogInput) => ({
organizationId: input.organizationId,
}),
actions: {
async append(c, body: AppendAuditLogCommand): Promise<{ ok: true }> {
const now = Date.now();
await c.db
.insert(events)
.values({
repoId: body.repoId ?? null,
taskId: body.taskId ?? null,
branchName: body.branchName ?? null,
kind: body.kind,
payloadJson: JSON.stringify(body.payload),
createdAt: now,
})
.run();
return { ok: true };
},
async list(c, params?: ListAuditLogParams): Promise<AuditLogEvent[]> {
const whereParts = [];
if (params?.repoId) {
whereParts.push(eq(events.repoId, params.repoId));
}
if (params?.taskId) {
whereParts.push(eq(events.taskId, params.taskId));
}
if (params?.branch) {
whereParts.push(eq(events.branchName, params.branch));
}
const base = c.db
.select({
id: events.id,
repoId: events.repoId,
taskId: events.taskId,
branchName: events.branchName,
kind: events.kind,
payloadJson: events.payloadJson,
createdAt: events.createdAt,
})
.from(events);
const rows = await (whereParts.length > 0 ? base.where(and(...whereParts)) : base)
.orderBy(desc(events.createdAt))
.limit(params?.limit ?? 100)
.all();
return rows.map((row) => ({
...row,
organizationId: c.state.organizationId,
repoId: row.repoId ?? null,
}));
},
},
});