feat(foundry): revert actions to queue/workflow pattern with direct sends

Revert actor communication from direct action calls to queue/workflow-based
patterns for better observability (workflow history in RivetKit inspector),
replay/recovery semantics, and idiomatic RivetKit usage.

- Add queue/workflow infrastructure to all actors: organization, task, user,
  github-data, sandbox, and audit-log
- Mutations route through named queues processed by workflow command loops
  with ctx.step() wrapping for c.state/c.db access and observability
- Remove command action wrappers (~460 lines) — callers use .send() directly
  to queue names with expectQueueResponse() for wait:true results
- Keep sendPrompt and runProcess as direct sandbox actions (long-running /
  large responses that would block the workflow loop or exceed 128KB limit)
- Fix workspace fire-and-forget calls (enqueueWorkspaceEnsureSession,
  enqueueWorkspaceRefresh) to self-send to task queue instead of calling
  directly outside workflow step context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 18:46:53 -07:00
parent 4111aebfce
commit a171956298
21 changed files with 1443 additions and 688 deletions

View file

@ -1,8 +1,45 @@
// @ts-nocheck
/**
* User workflow queue-based command loop.
*
* Auth mutation commands are dispatched through named queues and processed
* inside the workflow command loop for observability and replay semantics.
*/
import { eq, count as sqlCount, and } from "drizzle-orm";
import { Loop } from "rivetkit/workflow";
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { selfUser } from "../handles.js";
import { expectQueueResponse } from "../../services/queue.js";
import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js";
// ---------------------------------------------------------------------------
// Queue names
// ---------------------------------------------------------------------------
export const USER_QUEUE_NAMES = [
"user.command.auth.create",
"user.command.auth.update",
"user.command.auth.update_many",
"user.command.auth.delete",
"user.command.auth.delete_many",
"user.command.profile.upsert",
"user.command.session_state.upsert",
"user.command.task_state.upsert",
"user.command.task_state.delete",
] as const;
export type UserQueueName = (typeof USER_QUEUE_NAMES)[number];
export function userWorkflowQueueName(name: UserQueueName): UserQueueName {
return name;
}
// ---------------------------------------------------------------------------
// Mutation functions
// ---------------------------------------------------------------------------
export async function createAuthRecordMutation(c: any, input: { model: string; data: Record<string, unknown> }) {
const table = tableFor(input.model);
const persisted = persistInput(input.model, input.data);
@ -195,3 +232,66 @@ export async function deleteTaskStateMutation(c: any, input: { taskId: string; s
}
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
}
// ---------------------------------------------------------------------------
// Workflow command loop
// ---------------------------------------------------------------------------
type WorkflowHandler = (loopCtx: any, body: any) => Promise<any>;
const COMMAND_HANDLERS: Record<UserQueueName, WorkflowHandler> = {
"user.command.auth.create": async (c, body) => createAuthRecordMutation(c, body),
"user.command.auth.update": async (c, body) => updateAuthRecordMutation(c, body),
"user.command.auth.update_many": async (c, body) => updateManyAuthRecordsMutation(c, body),
"user.command.auth.delete": async (c, body) => {
await deleteAuthRecordMutation(c, body);
return { ok: true };
},
"user.command.auth.delete_many": async (c, body) => deleteManyAuthRecordsMutation(c, body),
"user.command.profile.upsert": async (c, body) => upsertUserProfileMutation(c, body),
"user.command.session_state.upsert": async (c, body) => upsertSessionStateMutation(c, body),
"user.command.task_state.upsert": async (c, body) => upsertTaskStateMutation(c, body),
"user.command.task_state.delete": async (c, body) => {
await deleteTaskStateMutation(c, body);
return { ok: true };
},
};
export async function runUserWorkflow(ctx: any): Promise<void> {
await ctx.loop("user-command-loop", async (loopCtx: any) => {
const msg = await loopCtx.queue.next("next-user-command", {
names: [...USER_QUEUE_NAMES],
completable: true,
});
if (!msg) {
return Loop.continue(undefined);
}
const handler = COMMAND_HANDLERS[msg.name as UserQueueName];
if (!handler) {
logActorWarning("user", "unknown user command", { command: msg.name });
await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {});
return Loop.continue(undefined);
}
try {
// Wrap in a step so c.state and c.db are accessible inside mutation functions.
const result = await loopCtx.step({
name: msg.name,
timeout: 60_000,
run: async () => handler(loopCtx, msg.body),
});
await msg.complete(result);
} catch (error) {
const message = resolveErrorMessage(error);
logActorWarning("user", "user workflow command failed", {
command: msg.name,
error: message,
});
await msg.complete({ error: message }).catch(() => {});
}
return Loop.continue(undefined);
});
}